From 215bf5b5d0a28305b03f80ff9e3fc60f2d1d3996 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Sun, 26 Nov 2023 18:47:45 -0500 Subject: [PATCH 01/39] initial naval fate example --- .gitignore | 2 + examples/naval-fate.ts | 178 ++++++++++++++++++++++++++++++++++ examples/naval-fate/domain.ts | 116 ++++++++++++++++++++++ examples/naval-fate/store.ts | 139 ++++++++++++++++++++++++++ tsconfig.base.json | 3 + 5 files changed, 438 insertions(+) create mode 100644 examples/naval-fate.ts create mode 100644 examples/naval-fate/domain.ts create mode 100644 examples/naval-fate/store.ts 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..7ce449c --- /dev/null +++ b/examples/naval-fate.ts @@ -0,0 +1,178 @@ +import { Args, CliApp, Command, Options } 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.unit, + onSome: (subcommand) => handleShipSubcommand(subcommand) + }) + } + case "MineCommand": { + return Option.match(command.subcommand, { + onNone: () => Effect.unit, + 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.unit, + 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/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": [ From b5f750aaa2be0d2f8e3ead689814d89147b7b472 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 27 Nov 2023 14:33:26 +1300 Subject: [PATCH 02/39] add HandledCommand module --- examples/naval-fate-2.ts | 135 +++++++++++++++++++++++++++++++++++++ src/HandledCommand.ts | 140 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 examples/naval-fate-2.ts create mode 100644 src/HandledCommand.ts diff --git a/examples/naval-fate-2.ts b/examples/naval-fate-2.ts new file mode 100644 index 0000000..6924d79 --- /dev/null +++ b/examples/naval-fate-2.ts @@ -0,0 +1,135 @@ +import { Args, CliApp, Command, Options } from "@effect/cli" +import * as Handled from "@effect/cli/HandledCommand" +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 NavalFateStore from "./naval-fate/store.js" + +const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunctions( + NavalFateStore.NavalFateStore +) + +// 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( + Handled.make("new", ({ args: name }) => + createShip(name).pipe( + Effect.zipRight(Console.log(`Created ship: '${name}'`)) + )) +) + +const moveShipCommand = Command.make("move", { + args: nameAndCoordinatesArg, + options: speedOption +}).pipe( + Handled.make("move", ({ args: { name, x, y }, options: speed }) => + moveShip(name, x, y).pipe( + Effect.zipRight( + Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`) + ) + )) +) + +const shootShipCommand = Command.make("shoot", { + args: coordinatesArg +}).pipe( + Handled.make("shoot", ({ args: { x, y } }) => + shoot(x, y).pipe( + Effect.zipRight(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + )) +) + +const shipCommand = Command.make("ship").pipe( + Handled.makeUnit("ship"), + Handled.withSubcommands([ + newShipCommand, + moveShipCommand, + shootShipCommand + ]) +) + +const setMineCommand = Command.make("set", { + args: coordinatesArg, + options: mooredOption +}).pipe( + Handled.make("set", ({ args: { x, y }, options: moored }) => + setMine(x, y).pipe( + Effect.zipRight( + Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) + ) + )) +) + +const removeMineCommand = Command.make("remove", { + args: coordinatesArg +}).pipe( + Handled.make("remove", ({ args: { x, y } }) => + removeMine(x, y).pipe( + Effect.zipRight(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + )) +) + +const mineCommand = Command.make("mine").pipe( + Handled.makeUnit("mine"), + Handled.withSubcommands([ + setMineCommand, + removeMineCommand + ]) +) + +const navalFate = Command.make("naval_fate").pipe( + Command.withDescription("An implementation of the Naval Fate CLI application."), + Handled.makeUnit("naval_fate"), + Handled.withSubcommands([shipCommand, mineCommand]) +) + +const navalFateApp = CliApp.make({ + name: "Naval Fate", + version: "1.0.0", + command: navalFate.command +}) + +const main = Effect.sync(() => globalThis.process.argv.slice(2)).pipe( + Effect.flatMap((argv) => + CliApp.run( + navalFateApp, + argv, + navalFate.handler + ) + ) +) + +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/src/HandledCommand.ts b/src/HandledCommand.ts new file mode 100644 index 0000000..f43bc3f --- /dev/null +++ b/src/HandledCommand.ts @@ -0,0 +1,140 @@ +/** + * @since 1.0.0 + */ +import * as Effect from "effect/Effect" +import { dual } from "effect/Function" +import type * as Option from "effect/Option" +import type { Pipeable } from "effect/Pipeable" +import ReadonlyArray from "effect/ReadonlyArray" +import * as Command from "./Command.js" + +/** + * @since 1.0.0 + * @category type ids + */ +export const TypeId = Symbol.for("@effect/cli/HandledCommand") + +/** + * @since 1.0.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * @since 1.0.0 + * @category models + */ +export interface HandledCommand extends Pipeable { + readonly [TypeId]: TypeId + readonly name: Name + readonly command: Command.Command + readonly handler: (_: A) => Effect.Effect +} + +const Prototype = { + [TypeId]: TypeId +} + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = dual< + ( + name: Name, + handler: (_: A) => Effect.Effect + ) => ( + command: Command.Command<{ readonly name: Name } & A> + ) => HandledCommand, + ( + command: Command.Command<{ readonly name: Name } & A>, + name: Name, + handler: (_: A) => Effect.Effect + ) => HandledCommand +>(3, (command, name, handler) => { + const self = Object.create(Prototype) + self.name = name + self.command = command + self.handler = handler + return self +}) + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeUnit = dual< + ( + name: Name + ) => ( + command: Command.Command<{ readonly name: Name } & A> + ) => HandledCommand, + ( + command: Command.Command<{ readonly name: Name } & A>, + name: Name + ) => HandledCommand +>(2, (command, name) => make(command, name, (_) => Effect.unit) as any) + +/** + * @since 1.0.0 + * @category combinators + */ +export const withSubcommands = dual< + < + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + >( + subcommands: Subcommand + ) => (self: HandledCommand) => HandledCommand< + Name, + Command.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option.Option> } + > + >, + R | Effect.Effect.Context>, + E | Effect.Effect.Error> + >, + < + Name extends string, + A, + R, + E, + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + >( + self: HandledCommand, + subcommands: Subcommand + ) => HandledCommand< + Name, + Command.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option.Option> } + > + >, + R | Effect.Effect.Context>, + E | Effect.Effect.Error> + > +>(2, (self, subcommands) => { + const command = Command.withSubcommands( + self.command, + ReadonlyArray.map(subcommands, (_) => _.command) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + {} as Record Effect.Effect>, + (handlers, subcommand) => { + handlers[subcommand.name] = subcommand.handler + return handlers + } + ) + const handler = ( + args: { readonly subcommand: Option.Option<{ readonly name: string }> } + ) => { + if (args.subcommand._tag === "Some") { + return handlers[args.subcommand.value.name](args.subcommand.value) + } + return self.handler(args as any) + } + return make(command as any, self.name, handler) as any +}) From 26a63c085e2bbc508c9006a6c219270d2bcc0f02 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 27 Nov 2023 15:16:46 +1300 Subject: [PATCH 03/39] cleanup --- examples/naval-fate-2.ts | 135 ----------------------- examples/naval-fate.ts | 194 +++++++++++++--------------------- examples/naval-fate/domain.ts | 48 --------- src/HandledCommand.ts | 104 +++++++++++------- src/index.ts | 5 + 5 files changed, 143 insertions(+), 343 deletions(-) delete mode 100644 examples/naval-fate-2.ts diff --git a/examples/naval-fate-2.ts b/examples/naval-fate-2.ts deleted file mode 100644 index 6924d79..0000000 --- a/examples/naval-fate-2.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Args, CliApp, Command, Options } from "@effect/cli" -import * as Handled from "@effect/cli/HandledCommand" -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 NavalFateStore from "./naval-fate/store.js" - -const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunctions( - NavalFateStore.NavalFateStore -) - -// 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( - Handled.make("new", ({ args: name }) => - createShip(name).pipe( - Effect.zipRight(Console.log(`Created ship: '${name}'`)) - )) -) - -const moveShipCommand = Command.make("move", { - args: nameAndCoordinatesArg, - options: speedOption -}).pipe( - Handled.make("move", ({ args: { name, x, y }, options: speed }) => - moveShip(name, x, y).pipe( - Effect.zipRight( - Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`) - ) - )) -) - -const shootShipCommand = Command.make("shoot", { - args: coordinatesArg -}).pipe( - Handled.make("shoot", ({ args: { x, y } }) => - shoot(x, y).pipe( - Effect.zipRight(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) - )) -) - -const shipCommand = Command.make("ship").pipe( - Handled.makeUnit("ship"), - Handled.withSubcommands([ - newShipCommand, - moveShipCommand, - shootShipCommand - ]) -) - -const setMineCommand = Command.make("set", { - args: coordinatesArg, - options: mooredOption -}).pipe( - Handled.make("set", ({ args: { x, y }, options: moored }) => - setMine(x, y).pipe( - Effect.zipRight( - Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) - ) - )) -) - -const removeMineCommand = Command.make("remove", { - args: coordinatesArg -}).pipe( - Handled.make("remove", ({ args: { x, y } }) => - removeMine(x, y).pipe( - Effect.zipRight(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) - )) -) - -const mineCommand = Command.make("mine").pipe( - Handled.makeUnit("mine"), - Handled.withSubcommands([ - setMineCommand, - removeMineCommand - ]) -) - -const navalFate = Command.make("naval_fate").pipe( - Command.withDescription("An implementation of the Naval Fate CLI application."), - Handled.makeUnit("naval_fate"), - Handled.withSubcommands([shipCommand, mineCommand]) -) - -const navalFateApp = CliApp.make({ - name: "Naval Fate", - version: "1.0.0", - command: navalFate.command -}) - -const main = Effect.sync(() => globalThis.process.argv.slice(2)).pipe( - Effect.flatMap((argv) => - CliApp.run( - navalFateApp, - argv, - navalFate.handler - ) - ) -) - -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.ts b/examples/naval-fate.ts index 7ce449c..5ebf43e 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -1,23 +1,16 @@ -import { Args, CliApp, Command, Options } from "@effect/cli" +import { Args, Command, HandledCommand, Options } 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" +const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunctions( + NavalFateStore.NavalFateStore +) + // naval_fate [-h | --help] [--version] // naval_fate ship new ... // naval_fate ship move [--speed=] @@ -39,133 +32,94 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const newShipCommand = Command.make("new", { - args: nameArg -}).pipe(Command.map(({ args }) => new NewShipCommand({ name: args }))) +const newShipCommand = HandledCommand.fromCommand( + Command.make("new", { + args: nameArg + }), + ({ args: name }) => + Effect.gen(function*(_) { + yield* _(createShip(name)) + yield* _(Console.log(`Created ship: '${name}'`)) + }) +) -const moveShipCommand = Command.make("move", { - args: nameAndCoordinatesArg, - options: speedOption -}).pipe(Command.map(({ args, options }) => new MoveShipCommand({ ...args, speed: options }))) +const moveShipCommand = HandledCommand.fromCommand( + Command.make("move", { + args: nameAndCoordinatesArg, + options: speedOption + }), + ({ args: { name, x, y }, options: speed }) => + Effect.gen(function*(_) { + yield* _(moveShip(name, x, y)) + yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) + }) +) -const shootShipCommand = Command.make("shoot", { - args: coordinatesArg -}).pipe(Command.map(({ args }) => new ShootShipCommand(args))) +const shootShipCommand = HandledCommand.fromCommand( + Command.make("shoot", { + args: coordinatesArg + }), + ({ args: { x, y } }) => + Effect.gen(function*(_) { + yield* _(shoot(x, y)) + yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + }) +) const shipCommand = Command.make("ship").pipe( - Command.withSubcommands([ + HandledCommand.fromCommandUnit, + HandledCommand.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 setMineCommand = HandledCommand.fromCommand( + Command.make("set", { + args: coordinatesArg, + options: mooredOption + }), + ({ args: { x, y }, options: moored }) => + Effect.gen(function*(_) { + yield* _(setMine(x, y)) + yield* _( + Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) + ) + }) +) -const removeMineCommand = Command.make("remove", { - args: coordinatesArg -}).pipe(Command.map(({ args }) => new RemoveMineCommand(args))) +const removeMineCommand = HandledCommand.fromCommand( + Command.make("remove", { + args: coordinatesArg + }), + ({ args: { x, y } }) => + Effect.gen(function*(_) { + yield* _(removeMine(x, y)) + yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + }) +) const mineCommand = Command.make("mine").pipe( - Command.withSubcommands([ + HandledCommand.fromCommandUnit, + HandledCommand.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.unit, - onSome: (subcommand) => handleShipSubcommand(subcommand) - }) - } - case "MineCommand": { - return Option.match(command.subcommand, { - onNone: () => Effect.unit, - 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 run = Command.make("naval_fate").pipe( + Command.withDescription("An implementation of the Naval Fate CLI application."), + HandledCommand.fromCommandUnit, + HandledCommand.withSubcommands([shipCommand, mineCommand]), + HandledCommand.toAppAndRun({ + name: "Naval Fate", + version: "1.0.0" }) - -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.unit, - onSome: (subcommand) => handleSubcommand(subcommand) - }) - ) - ) - ) ) +const main = Effect.suspend(() => run(globalThis.process.argv.slice(2))) + const MainLayer = NavalFateStore.layer.pipe( Layer.use(KeyValueStore.layerFileSystem("naval-fate-store")), Layer.merge(NodeContext.layer) diff --git a/examples/naval-fate/domain.ts b/examples/naval-fate/domain.ts index fff32d4..2f1b2a2 100644 --- a/examples/naval-fate/domain.ts +++ b/examples/naval-fate/domain.ts @@ -1,6 +1,5 @@ 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 @@ -39,10 +38,6 @@ export class Ship extends Schema.Class()({ 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 { @@ -65,52 +60,9 @@ 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/src/HandledCommand.ts b/src/HandledCommand.ts index f43bc3f..6af7be8 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -4,9 +4,13 @@ import * as Effect from "effect/Effect" import { dual } from "effect/Function" import type * as Option from "effect/Option" -import type { Pipeable } from "effect/Pipeable" -import ReadonlyArray from "effect/ReadonlyArray" +import { type Pipeable, pipeArguments } from "effect/Pipeable" +import * as ReadonlyArray from "effect/ReadonlyArray" +import * as CliApp from "./CliApp.js" import * as Command from "./Command.js" +import type { HelpDoc } from "./HelpDoc.js" +import type { Span } from "./HelpDoc/Span.js" +import type { ValidationError } from "./ValidationError.js" /** * @since 1.0.0 @@ -24,36 +28,33 @@ export type TypeId = typeof TypeId * @since 1.0.0 * @category models */ -export interface HandledCommand extends Pipeable { +export interface HandledCommand extends Pipeable { readonly [TypeId]: TypeId - readonly name: Name readonly command: Command.Command readonly handler: (_: A) => Effect.Effect } const Prototype = { - [TypeId]: TypeId + [TypeId]: TypeId, + pipe() { + return pipeArguments(this, arguments) + } } /** * @since 1.0.0 * @category constructors */ -export const make = dual< - ( - name: Name, +export const fromCommand = dual< + ( handler: (_: A) => Effect.Effect - ) => ( - command: Command.Command<{ readonly name: Name } & A> - ) => HandledCommand, - ( - command: Command.Command<{ readonly name: Name } & A>, - name: Name, + ) => (command: Command.Command) => HandledCommand, + ( + command: Command.Command, handler: (_: A) => Effect.Effect - ) => HandledCommand ->(3, (command, name, handler) => { + ) => HandledCommand +>(2, (command, handler) => { const self = Object.create(Prototype) - self.name = name self.command = command self.handler = handler return self @@ -63,29 +64,18 @@ export const make = dual< * @since 1.0.0 * @category constructors */ -export const makeUnit = dual< - ( - name: Name - ) => ( - command: Command.Command<{ readonly name: Name } & A> - ) => HandledCommand, - ( - command: Command.Command<{ readonly name: Name } & A>, - name: Name - ) => HandledCommand ->(2, (command, name) => make(command, name, (_) => Effect.unit) as any) +export const fromCommandUnit = ( + command: Command.Command +) => fromCommand(command, (_) => Effect.unit) /** * @since 1.0.0 * @category combinators */ export const withSubcommands = dual< - < - Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> - >( + >>( subcommands: Subcommand - ) => (self: HandledCommand) => HandledCommand< - Name, + ) => (self: HandledCommand) => HandledCommand< Command.Command.ComputeParsedType< & A & Readonly< @@ -96,16 +86,14 @@ export const withSubcommands = dual< E | Effect.Effect.Error> >, < - Name extends string, A, R, E, - Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> >( - self: HandledCommand, + self: HandledCommand, subcommands: Subcommand ) => HandledCommand< - Name, Command.Command.ComputeParsedType< & A & Readonly< @@ -124,17 +112,53 @@ export const withSubcommands = dual< subcommands, {} as Record Effect.Effect>, (handlers, subcommand) => { - handlers[subcommand.name] = subcommand.handler + for (const name of Command.getNames(subcommand.command)) { + handlers[name] = subcommand.handler + } return handlers } ) const handler = ( - args: { readonly subcommand: Option.Option<{ readonly name: string }> } + args: { + readonly name: string + readonly subcommand: Option.Option<{ readonly name: string }> + } ) => { if (args.subcommand._tag === "Some") { return handlers[args.subcommand.value.name](args.subcommand.value) } return self.handler(args as any) } - return make(command as any, self.name, handler) as any + return fromCommand(command as any, handler) as any +}) + +/** + * @since 1.0.0 + * @category combinators + */ +export const toAppAndRun = dual< + (config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + }) => ( + self: HandledCommand + ) => ( + args: ReadonlyArray + ) => Effect.Effect, + (self: HandledCommand, config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + }) => ( + args: ReadonlyArray + ) => Effect.Effect +>(2, (self, config) => { + const app = CliApp.make({ + ...config, + command: self.command + }) + return (args) => CliApp.run(app, args, self.handler) }) diff --git a/src/index.ts b/src/index.ts index 933b4a8..f41ef51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,11 @@ export * as Command from "./Command.js" */ export * as CommandDirective from "./CommandDirective.js" +/** + * @since 1.0.0 + */ +export * as HandledCommand from "./HandledCommand.js" + /** * @since 1.0.0 */ From e4f400b7750f4576405da032e4ce05802e19a65b Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 27 Nov 2023 15:44:53 +1300 Subject: [PATCH 04/39] .make --- examples/naval-fate.ts | 99 +++++++++++++++++------------------------- src/HandledCommand.ts | 52 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 58 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 5ebf43e..b2354d8 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,42 +32,32 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const newShipCommand = HandledCommand.fromCommand( - Command.make("new", { - args: nameArg - }), - ({ args: name }) => - Effect.gen(function*(_) { - yield* _(createShip(name)) - yield* _(Console.log(`Created ship: '${name}'`)) - }) -) +const newShipCommand = HandledCommand.make("new", { + args: nameArg +}, ({ args: name }) => + Effect.gen(function*(_) { + yield* _(createShip(name)) + yield* _(Console.log(`Created ship: '${name}'`)) + })) -const moveShipCommand = HandledCommand.fromCommand( - Command.make("move", { - args: nameAndCoordinatesArg, - options: speedOption - }), - ({ args: { name, x, y }, options: speed }) => - Effect.gen(function*(_) { - yield* _(moveShip(name, x, y)) - yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) - }) -) +const moveShipCommand = HandledCommand.make("move", { + args: nameAndCoordinatesArg, + options: speedOption +}, ({ args: { name, x, y }, options: speed }) => + Effect.gen(function*(_) { + yield* _(moveShip(name, x, y)) + yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) + })) -const shootShipCommand = HandledCommand.fromCommand( - Command.make("shoot", { - args: coordinatesArg - }), - ({ args: { x, y } }) => - Effect.gen(function*(_) { - yield* _(shoot(x, y)) - yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) - }) -) +const shootShipCommand = HandledCommand.make("shoot", { + args: coordinatesArg +}, ({ args: { x, y } }) => + Effect.gen(function*(_) { + yield* _(shoot(x, y)) + yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + })) -const shipCommand = Command.make("ship").pipe( - HandledCommand.fromCommandUnit, +const shipCommand = HandledCommand.makeUnit("ship").pipe( HandledCommand.withSubcommands([ newShipCommand, moveShipCommand, @@ -75,33 +65,26 @@ const shipCommand = Command.make("ship").pipe( ]) ) -const setMineCommand = HandledCommand.fromCommand( - Command.make("set", { - args: coordinatesArg, - options: mooredOption - }), - ({ args: { x, y }, options: moored }) => - Effect.gen(function*(_) { - yield* _(setMine(x, y)) - yield* _( - Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) - ) - }) -) +const setMineCommand = HandledCommand.make("set", { + args: coordinatesArg, + options: mooredOption +}, ({ args: { x, y }, options: moored }) => + Effect.gen(function*(_) { + yield* _(setMine(x, y)) + yield* _( + Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) + ) + })) -const removeMineCommand = HandledCommand.fromCommand( - Command.make("remove", { - args: coordinatesArg - }), - ({ args: { x, y } }) => - Effect.gen(function*(_) { - yield* _(removeMine(x, y)) - yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) - }) -) +const removeMineCommand = HandledCommand.make("remove", { + args: coordinatesArg +}, ({ args: { x, y } }) => + Effect.gen(function*(_) { + yield* _(removeMine(x, y)) + yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + })) -const mineCommand = Command.make("mine").pipe( - HandledCommand.fromCommandUnit, +const mineCommand = HandledCommand.makeUnit("mine").pipe( HandledCommand.withSubcommands([ setMineCommand, removeMineCommand diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index 6af7be8..7d22d1a 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -68,6 +68,58 @@ export const fromCommandUnit = ( command: Command.Command ) => fromCommand(command, (_) => Effect.unit) +/** + * @since 1.0.0 + * @category constructors + */ +export const fromCommandOrDie = ( + command: Command.Command, + orDie: () => unknown +) => fromCommand(command, (_) => Effect.dieSync(orDie)) + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = ( + name: Name, + config: Command.Command.ConstructorConfig, + handler: ( + _: { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType } + ) => Effect.Effect +): HandledCommand< + { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, + R, + E +> => fromCommand(Command.make(name, config), handler) + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeUnit = ( + name: Name, + config?: Command.Command.ConstructorConfig +): HandledCommand< + { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, + never, + never +> => fromCommandUnit(Command.make(name, config)) + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeOrDie = ( + name: Name, + config: Command.Command.ConstructorConfig, + orDie: () => unknown +): HandledCommand< + { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, + never, + never +> => fromCommandOrDie(Command.make(name, config), orDie) + /** * @since 1.0.0 * @category combinators From 3b511106e0ecd1793782424e9745a76311bd4c9b Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 27 Nov 2023 17:05:39 +1300 Subject: [PATCH 05/39] add toString to errors --- examples/naval-fate/domain.ts | 26 +++++++++++++++++++------- src/HandledCommand.ts | 4 ++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/examples/naval-fate/domain.ts b/examples/naval-fate/domain.ts index 2f1b2a2..167d799 100644 --- a/examples/naval-fate/domain.ts +++ b/examples/naval-fate/domain.ts @@ -7,7 +7,11 @@ import * as Data from "effect/Data" */ export class ShipExistsError extends Data.TaggedError("ShipExistsError")<{ readonly name: string -}> {} +}> { + toString(): string { + return `ShipExistsError: ship with name '${this.name}' already exists` + } +} /** * An error that occurs when attempting to move a Naval Fate ship that does not @@ -17,7 +21,11 @@ export class ShipNotFoundError extends Data.TaggedError("ShipNotFoundError")<{ readonly name: string readonly x: number readonly y: number -}> {} +}> { + toString(): string { + return `ShipNotFoundError: ship with name '${this.name}' does not exist` + } +} /** * An error that occurs when attempting to move a Naval Fate ship to coordinates @@ -27,15 +35,19 @@ export class CoordinatesOccupiedError extends Data.TaggedError("CoordinatesOccup readonly name: string readonly x: number readonly y: number -}> {} +}> { + toString(): string { + return `CoordinatesOccupiedError: ship with name '${this.name}' already occupies coordinates (${this.x}, ${this.y})` + } +} /** * Represents a Naval Fate ship. */ export class Ship extends Schema.Class()({ name: Schema.string, - x: Schema.NumberFromString, - y: Schema.NumberFromString, + x: Schema.number, + y: Schema.number, status: Schema.literal("sailing", "destroyed") }) { static readonly create = (name: string) => new Ship({ name, x: 0, y: 0, status: "sailing" }) @@ -57,8 +69,8 @@ export class Ship extends Schema.Class()({ * Represents a Naval Fate mine. */ export class Mine extends Schema.Class()({ - x: Schema.NumberFromString, - y: Schema.NumberFromString + x: Schema.number, + y: Schema.number }) { static readonly create = (x: number, y: number) => new Mine({ x, y }) diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index 7d22d1a..a859fa5 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -66,7 +66,7 @@ export const fromCommand = dual< */ export const fromCommandUnit = ( command: Command.Command -) => fromCommand(command, (_) => Effect.unit) +): HandledCommand => fromCommand(command, (_) => Effect.unit) /** * @since 1.0.0 @@ -75,7 +75,7 @@ export const fromCommandUnit = ( export const fromCommandOrDie = ( command: Command.Command, orDie: () => unknown -) => fromCommand(command, (_) => Effect.dieSync(orDie)) +): HandledCommand => fromCommand(command, (_) => Effect.dieSync(orDie)) /** * @since 1.0.0 From 0769a5df601f9300b06a895c0e74ab5e884e657d Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 27 Nov 2023 08:11:53 -0500 Subject: [PATCH 06/39] fix docs generation and add ValidationError.helpRequested --- examples/naval-fate.ts | 8 +-- src/ValidationError.ts | 28 ++++++++ src/internal/builtInOptions.ts | 4 +- src/internal/cliApp.ts | 9 ++- src/internal/cliConfig.ts | 2 +- src/internal/command.ts | 116 +++++++++++++++++--------------- src/internal/validationError.ts | 8 ++- test/Command.test.ts | 4 +- 8 files changed, 114 insertions(+), 65 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 7ce449c..f404786 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -1,4 +1,4 @@ -import { Args, CliApp, Command, Options } from "@effect/cli" +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" @@ -93,13 +93,13 @@ const handleSubcommand = (command: ShipCommand | MineCommand) => { switch (command._tag) { case "ShipCommand": { return Option.match(command.subcommand, { - onNone: () => Effect.unit, + onNone: () => Effect.fail(ValidationError.helpRequested(shipCommand)), onSome: (subcommand) => handleShipSubcommand(subcommand) }) } case "MineCommand": { return Option.match(command.subcommand, { - onNone: () => Effect.unit, + onNone: () => Effect.fail(ValidationError.helpRequested(mineCommand)), onSome: (subcommand) => handleMineSubcommand(subcommand) }) } @@ -158,7 +158,7 @@ const main = Effect.sync(() => globalThis.process.argv.slice(2)).pipe( argv, Effect.unifiedFn((args) => Option.match(args.subcommand, { - onNone: () => Effect.unit, + onNone: () => Effect.fail(ValidationError.helpRequested(navalFate)), onSome: (subcommand) => handleSubcommand(subcommand) }) ) 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/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..e3711fc 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( 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..964173d 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) @@ -995,7 +988,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 +1339,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/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 |` )) }) From c78e324938f4ec86edff77759c2a25f68ed909ce Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 27 Nov 2023 14:53:53 -0500 Subject: [PATCH 07/39] fix wizard mode --- src/internal/args.ts | 2 +- src/internal/cliApp.ts | 3 +- src/internal/command.ts | 159 +++++++++++++++++++++++++------------- src/internal/options.ts | 27 ++++--- src/internal/primitive.ts | 2 +- 5 files changed, 122 insertions(+), 71 deletions(-) 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/cliApp.ts b/src/internal/cliApp.ts index e3711fc..8378639 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -217,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/command.ts b/src/internal/command.ts index 964173d..80b2e9c 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -841,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 } } } } 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}`)) } From ac280c8c3223bfd29d298195a712d24e8405ef0b Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 10:51:47 +1300 Subject: [PATCH 08/39] handled context (#391) --- examples/naval-fate.ts | 32 ++++++---- src/HandledCommand.ts | 132 ++++++++++++++++++++++++++++------------- 2 files changed, 110 insertions(+), 54 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index b2354d8..7f64248 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,12 +32,20 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) +const shipCommandParent = HandledCommand.makeRequestHelp("ship", { + options: Options.withDefault(Options.boolean("verbose"), false) +}) + const newShipCommand = HandledCommand.make("new", { args: nameArg }, ({ args: name }) => Effect.gen(function*(_) { + const { options: verbose } = yield* _(shipCommandParent) yield* _(createShip(name)) yield* _(Console.log(`Created ship: '${name}'`)) + if (verbose) { + yield* _(Console.log(`Verbose mode enabled`)) + } })) const moveShipCommand = HandledCommand.make("move", { @@ -57,13 +65,13 @@ const shootShipCommand = HandledCommand.make("shoot", { yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) })) -const shipCommand = HandledCommand.makeUnit("ship").pipe( - HandledCommand.withSubcommands([ - newShipCommand, - moveShipCommand, - shootShipCommand - ]) -) +const shipCommand = HandledCommand.withSubcommands(shipCommandParent, [ + newShipCommand, + moveShipCommand, + shootShipCommand +]) + +const mineCommandParent = HandledCommand.makeRequestHelp("mine") const setMineCommand = HandledCommand.make("set", { args: coordinatesArg, @@ -84,12 +92,10 @@ const removeMineCommand = HandledCommand.make("remove", { yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) })) -const mineCommand = HandledCommand.makeUnit("mine").pipe( - HandledCommand.withSubcommands([ - setMineCommand, - removeMineCommand - ]) -) +const mineCommand = HandledCommand.withSubcommands(mineCommandParent, [ + setMineCommand, + removeMineCommand +]) const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index a859fa5..c91fa58 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -1,8 +1,11 @@ /** * @since 1.0.0 */ +import * as Context from "effect/Context" import * as Effect from "effect/Effect" +import * as Effectable from "effect/Effectable" import { dual } from "effect/Function" +import { globalValue } from "effect/GlobalValue" import type * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" @@ -10,7 +13,8 @@ import * as CliApp from "./CliApp.js" import * as Command from "./Command.js" import type { HelpDoc } from "./HelpDoc.js" import type { Span } from "./HelpDoc/Span.js" -import type { ValidationError } from "./ValidationError.js" +import type { HandledCommand } from "./index.js" +import * as ValidationError from "./ValidationError.js" /** * @since 1.0.0 @@ -28,14 +32,21 @@ export type TypeId = typeof TypeId * @since 1.0.0 * @category models */ -export interface HandledCommand extends Pipeable { +export interface HandledCommand + extends Pipeable, Effect.Effect, never, A> +{ readonly [TypeId]: TypeId readonly command: Command.Command readonly handler: (_: A) => Effect.Effect + readonly tag: Context.Tag, A> } const Prototype = { + ...Effectable.CommitPrototype, [TypeId]: TypeId, + commit(this: HandledCommand) { + return this.tag + }, pipe() { return pipeArguments(this, arguments) } @@ -57,9 +68,37 @@ export const fromCommand = dual< const self = Object.create(Prototype) self.command = command self.handler = handler + self.tag = Context.Tag() return self }) +const modifiedCommands = globalValue( + "@effect/cli/HandledCommand/modifiedCommands", + () => new WeakMap, Command.Command>() +) + +const getCommand = (self: HandledCommand) => + modifiedCommands.get(self.tag) ?? self.command + +/** + * @since 1.0.0 + * @category combinators + */ +export const modify = dual< + (f: (_: HandledCommand) => HandledCommand) => ( + self: HandledCommand + ) => HandledCommand, + ( + self: HandledCommand, + f: (_: HandledCommand) => HandledCommand + ) => HandledCommand +>(2, (self, f) => { + const command = f(self) + ;(command as any).tag = self.tag + modifiedCommands.set(self.tag, command.command) + return command +}) + /** * @since 1.0.0 * @category constructors @@ -72,10 +111,15 @@ export const fromCommandUnit = ( * @since 1.0.0 * @category constructors */ -export const fromCommandOrDie = ( - command: Command.Command, - orDie: () => unknown -): HandledCommand => fromCommand(command, (_) => Effect.dieSync(orDie)) +export const fromCommandRequestHelp = ( + command: Command.Command +): HandledCommand => { + const self: HandledCommand = fromCommand( + command, + (_) => Effect.fail(ValidationError.helpRequested(getCommand(self))) + ) + return self +} /** * @since 1.0.0 @@ -110,15 +154,14 @@ export const makeUnit = ( +export const makeRequestHelp = ( name: Name, - config: Command.Command.ConstructorConfig, - orDie: () => unknown + config?: Command.Command.ConstructorConfig ): HandledCommand< { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, never, - never -> => fromCommandOrDie(Command.make(name, config), orDie) + ValidationError.ValidationError +> => fromCommandRequestHelp(Command.make(name, config)) /** * @since 1.0.0 @@ -134,7 +177,8 @@ export const withSubcommands = dual< { subcommand: Option.Option> } > >, - R | Effect.Effect.Context>, + | R + | Exclude>, Command.Command>, E | Effect.Effect.Error> >, < @@ -152,37 +196,43 @@ export const withSubcommands = dual< { subcommand: Option.Option> } > >, - R | Effect.Effect.Context>, + | R + | Exclude>, Command.Command>, E | Effect.Effect.Error> > ->(2, (self, subcommands) => { - const command = Command.withSubcommands( - self.command, - ReadonlyArray.map(subcommands, (_) => _.command) - ) - const handlers = ReadonlyArray.reduce( - subcommands, - {} as Record Effect.Effect>, - (handlers, subcommand) => { - for (const name of Command.getNames(subcommand.command)) { - handlers[name] = subcommand.handler +>(2, (self, subcommands) => + modify(self, () => { + const command = Command.withSubcommands( + self.command, + ReadonlyArray.map(subcommands, (_) => _.command) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + {} as Record Effect.Effect>, + (handlers, subcommand) => { + for (const name of Command.getNames(subcommand.command)) { + handlers[name] = subcommand.handler + } + return handlers } - return handlers - } - ) - const handler = ( - args: { - readonly name: string - readonly subcommand: Option.Option<{ readonly name: string }> - } - ) => { - if (args.subcommand._tag === "Some") { - return handlers[args.subcommand.value.name](args.subcommand.value) + ) + function handler( + args: { + readonly name: string + readonly subcommand: Option.Option<{ readonly name: string }> + } + ) { + if (args.subcommand._tag === "Some") { + return Effect.provideService( + handlers[args.subcommand.value.name](args.subcommand.value), + self.tag, + args as any + ) + } + return self.handler(args as any) } - return self.handler(args as any) - } - return fromCommand(command as any, handler) as any -}) + return fromCommand(command as any, handler) as any + })) /** * @since 1.0.0 @@ -198,7 +248,7 @@ export const toAppAndRun = dual< self: HandledCommand ) => ( args: ReadonlyArray - ) => Effect.Effect, + ) => Effect.Effect, (self: HandledCommand, config: { readonly name: string readonly version: string @@ -206,7 +256,7 @@ export const toAppAndRun = dual< readonly footer?: HelpDoc | undefined }) => ( args: ReadonlyArray - ) => Effect.Effect + ) => Effect.Effect >(2, (self, config) => { const app = CliApp.make({ ...config, From e9325cfcb1a2bc5f87e2aa0533266f331d93fc0b Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 12:36:58 +1300 Subject: [PATCH 09/39] ParsedConfig --- examples/naval-fate.ts | 44 +++--- src/HandledCommand.ts | 343 +++++++++++++++++++++++++++++------------ 2 files changed, 271 insertions(+), 116 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 7f64248..844dbd7 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -21,8 +21,8 @@ const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunct 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 coordinatesArg = { x: xArg, y: yArg } +const nameAndCoordinatesArg = { name: nameArg, ...coordinatesArg } const mooredOption = Options.boolean("moored").pipe( Options.withDescription("Whether the mine is moored (anchored) or drifting") @@ -33,14 +33,14 @@ const speedOption = Options.integer("speed").pipe( ) const shipCommandParent = HandledCommand.makeRequestHelp("ship", { - options: Options.withDefault(Options.boolean("verbose"), false) + verbose: Options.withDefault(Options.boolean("verbose"), false) }) const newShipCommand = HandledCommand.make("new", { - args: nameArg -}, ({ args: name }) => + name: nameArg +}, ({ name }) => Effect.gen(function*(_) { - const { options: verbose } = yield* _(shipCommandParent) + const { verbose } = yield* _(shipCommandParent) yield* _(createShip(name)) yield* _(Console.log(`Created ship: '${name}'`)) if (verbose) { @@ -49,21 +49,23 @@ const newShipCommand = HandledCommand.make("new", { })) const moveShipCommand = HandledCommand.make("move", { - args: nameAndCoordinatesArg, - options: speedOption -}, ({ args: { name, x, y }, options: speed }) => + ...nameAndCoordinatesArg, + speed: speedOption +}, ({ name, speed, x, y }) => Effect.gen(function*(_) { yield* _(moveShip(name, x, y)) yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) })) -const shootShipCommand = HandledCommand.make("shoot", { - args: coordinatesArg -}, ({ args: { x, y } }) => - Effect.gen(function*(_) { - yield* _(shoot(x, y)) - yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) - })) +const shootShipCommand = HandledCommand.make( + "shoot", + { ...coordinatesArg }, + ({ x, y }) => + Effect.gen(function*(_) { + yield* _(shoot(x, y)) + yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + }) +) const shipCommand = HandledCommand.withSubcommands(shipCommandParent, [ newShipCommand, @@ -74,9 +76,9 @@ const shipCommand = HandledCommand.withSubcommands(shipCommandParent, [ const mineCommandParent = HandledCommand.makeRequestHelp("mine") const setMineCommand = HandledCommand.make("set", { - args: coordinatesArg, - options: mooredOption -}, ({ args: { x, y }, options: moored }) => + ...coordinatesArg, + moored: mooredOption +}, ({ moored, x, y }) => Effect.gen(function*(_) { yield* _(setMine(x, y)) yield* _( @@ -85,8 +87,8 @@ const setMineCommand = HandledCommand.make("set", { })) const removeMineCommand = HandledCommand.make("remove", { - args: coordinatesArg -}, ({ args: { x, y } }) => + ...coordinatesArg +}, ({ x, y }) => Effect.gen(function*(_) { yield* _(removeMine(x, y)) yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index c91fa58..0c707ea 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -9,11 +9,13 @@ import { globalValue } from "effect/GlobalValue" import type * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" +import type * as Types from "effect/Types" +import * as Args from "./Args.js" import * as CliApp from "./CliApp.js" import * as Command from "./Command.js" import type { HelpDoc } from "./HelpDoc.js" import type { Span } from "./HelpDoc/Span.js" -import type { HandledCommand } from "./index.js" +import * as Options from "./Options.js" import * as ValidationError from "./ValidationError.js" /** @@ -41,6 +43,144 @@ export interface HandledCommand readonly tag: Context.Tag, A> } +/** + * @since 1.0.0 + * @category models + */ +export declare namespace HandledCommand { + /** + * @since 1.0.0 + * @category models + */ + export interface ConfigBase { + readonly [key: string]: + | Args.Args + | Options.Options + | ReadonlyArray | Options.Options | ConfigBase> + | ConfigBase + } + + /** + * @since 1.0.0 + * @category models + */ + export type ParseConfig = Types.Simplify< + { + readonly [Key in keyof A]: A[Key] extends Args.Args ? Value + : A[Key] extends Options.Options ? Value + : A[Key] extends ConfigBase ? ParseConfig + : never + } + > + + interface ParsedConfigTree { + [key: string]: ParsedConfigNode + } + + type ParsedConfigNode = { + readonly _tag: "Args" + readonly index: number + } | { + readonly _tag: "Options" + readonly index: number + } | { + readonly _tag: "Array" + readonly children: ReadonlyArray + } | { + readonly _tag: "ParsedConfig" + readonly tree: ParsedConfigTree + } + + /** + * @since 1.0.0 + * @category models + */ + export interface ParsedConfig { + readonly args: ReadonlyArray> + readonly options: ReadonlyArray> + readonly tree: ParsedConfigTree + } +} + +const parseConfig = (config: HandledCommand.ConfigBase): HandledCommand.ParsedConfig => { + const args: Array> = [] + let argsIndex = 0 + const options: Array> = [] + let optionsIndex = 0 + const tree: HandledCommand.ParsedConfigTree = {} + + function parse(config: HandledCommand.ConfigBase) { + for (const key in config) { + tree[key] = parseValue(config[key]) + } + return tree + } + + function parseValue( + value: + | Args.Args + | Options.Options + | ReadonlyArray | Options.Options | HandledCommand.ConfigBase> + | HandledCommand.ConfigBase + ): HandledCommand.ParsedConfigNode { + if (Array.isArray(value)) { + return { + _tag: "Array", + children: ReadonlyArray.map(value, parseValue) + } + } else if (Args.isArgs(value)) { + args.push(value) + return { + _tag: "Args", + index: argsIndex++ + } + } else if (Options.isOptions(value)) { + options.push(value) + return { + _tag: "Options", + index: optionsIndex++ + } + } else { + return { + _tag: "ParsedConfig", + tree: parse(value as any) + } + } + } + + return { + args, + options, + tree: parse(config) + } +} + +const reconstructConfigTree = ( + tree: HandledCommand.ParsedConfigTree, + args: ReadonlyArray, + options: ReadonlyArray +): Record => { + const output: Record = {} + + for (const key in tree) { + output[key] = nodeValue(tree[key]) + } + + return output + + function nodeValue(node: HandledCommand.ParsedConfigNode): any { + if (node._tag === "Args") { + return args[node.index] + } else if (node._tag === "Options") { + return options[node.index] + } else if (node._tag === "Array") { + return ReadonlyArray.map(node.children, nodeValue) + } else { + return reconstructConfigTree(node.tree, args, options) + } + } +} + const Prototype = { ...Effectable.CommitPrototype, [TypeId]: TypeId, @@ -52,26 +192,6 @@ const Prototype = { } } -/** - * @since 1.0.0 - * @category constructors - */ -export const fromCommand = dual< - ( - handler: (_: A) => Effect.Effect - ) => (command: Command.Command) => HandledCommand, - ( - command: Command.Command, - handler: (_: A) => Effect.Effect - ) => HandledCommand ->(2, (command, handler) => { - const self = Object.create(Prototype) - self.command = command - self.handler = handler - self.tag = Context.Tag() - return self -}) - const modifiedCommands = globalValue( "@effect/cli/HandledCommand/modifiedCommands", () => new WeakMap, Command.Command>() @@ -80,88 +200,124 @@ const modifiedCommands = globalValue( const getCommand = (self: HandledCommand) => modifiedCommands.get(self.tag) ?? self.command +const HandledCommand = ( + command: Command.Command, + handler: (_: A) => Effect.Effect, + tag?: Context.Tag +): HandledCommand => { + const self = Object.create(Prototype) + self.command = Command.map(command, (args) => + new Proxy(args as any, { + get(target, p, _receiver) { + if (p === TypeId) { + return self.tag + } + return target[p as any] + }, + has(target, p) { + return p === TypeId || p in target + } + })) + self.handler = handler + self.tag = tag ?? Context.Tag() + modifiedCommands.set(self.tag, self.command) + return self +} + /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const modify = dual< - (f: (_: HandledCommand) => HandledCommand) => ( - self: HandledCommand - ) => HandledCommand, - ( - self: HandledCommand, - f: (_: HandledCommand) => HandledCommand - ) => HandledCommand ->(2, (self, f) => { - const command = f(self) - ;(command as any).tag = self.tag - modifiedCommands.set(self.tag, command.command) - return command -}) +export const fromCommand = dual< + ( + handler: (_: A) => Effect.Effect + ) => (command: Command.Command) => HandledCommand, + ( + command: Command.Command, + handler: (_: A) => Effect.Effect + ) => HandledCommand +>(2, (command, handler) => HandledCommand(command, handler)) /** * @since 1.0.0 * @category constructors */ -export const fromCommandUnit = ( +export const fromCommandUnit = ( command: Command.Command -): HandledCommand => fromCommand(command, (_) => Effect.unit) +): HandledCommand => HandledCommand(command, (_) => Effect.unit) /** * @since 1.0.0 * @category constructors */ -export const fromCommandRequestHelp = ( +export const fromCommandRequestHelp = ( command: Command.Command ): HandledCommand => { - const self: HandledCommand = fromCommand( + const self: HandledCommand = HandledCommand( command, (_) => Effect.fail(ValidationError.helpRequested(getCommand(self))) ) return self } +const makeCommand = ( + name: string, + config: Config +): Command.Command>> => { + const { args, options, tree } = parseConfig(config) + return Command.map( + Command.make(name, { + args: Args.all(args), + options: Options.all(options) + }), + ({ args, options }) => reconstructConfigTree(tree, args, options) + ) as any +} + /** * @since 1.0.0 * @category constructors */ -export const make = ( +export const make = ( name: Name, - config: Command.Command.ConstructorConfig, - handler: ( - _: { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType } - ) => Effect.Effect + config: Config, + handler: (_: Types.Simplify>) => Effect.Effect ): HandledCommand< - { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, + Types.Simplify>, R, E -> => fromCommand(Command.make(name, config), handler) +> => fromCommand(makeCommand(name, config), handler) /** * @since 1.0.0 * @category constructors */ -export const makeUnit = ( - name: Name, - config?: Command.Command.ConstructorConfig -): HandledCommand< - { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, - never, - never -> => fromCommandUnit(Command.make(name, config)) +export const makeUnit: { + ( + name: Name + ): HandledCommand<{}, never, never> + ( + name: Name, + config: Config + ): HandledCommand< + Types.Simplify>, + never, + never + > +} = (name: string, config = {}) => fromCommandUnit(makeCommand(name, config)) /** * @since 1.0.0 * @category constructors */ -export const makeRequestHelp = ( - name: Name, - config?: Command.Command.ConstructorConfig -): HandledCommand< - { readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }, - never, - ValidationError.ValidationError -> => fromCommandRequestHelp(Command.make(name, config)) +export const makeRequestHelp: { + (name: string): HandledCommand<{}, never, ValidationError.ValidationError> + (name: string, config: Config): HandledCommand< + Types.Simplify>, + never, + ValidationError.ValidationError + > +} = (name: string, config = {}) => fromCommandRequestHelp(makeCommand(name, config)) as any /** * @since 1.0.0 @@ -200,39 +356,36 @@ export const withSubcommands = dual< | Exclude>, Command.Command>, E | Effect.Effect.Error> > ->(2, (self, subcommands) => - modify(self, () => { - const command = Command.withSubcommands( - self.command, - ReadonlyArray.map(subcommands, (_) => _.command) - ) - const handlers = ReadonlyArray.reduce( - subcommands, - {} as Record Effect.Effect>, - (handlers, subcommand) => { - for (const name of Command.getNames(subcommand.command)) { - handlers[name] = subcommand.handler - } - return handlers - } - ) - function handler( - args: { - readonly name: string - readonly subcommand: Option.Option<{ readonly name: string }> - } - ) { - if (args.subcommand._tag === "Some") { - return Effect.provideService( - handlers[args.subcommand.value.name](args.subcommand.value), - self.tag, - args as any - ) - } - return self.handler(args as any) +>(2, (self, subcommands) => { + const command = Command.withSubcommands( + self.command, + ReadonlyArray.map(subcommands, (_) => _.command) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + new Map, (_: any) => Effect.Effect>(), + (handlers, subcommand) => { + handlers.set(subcommand.tag, subcommand.handler) + return handlers } - return fromCommand(command as any, handler) as any - })) + ) + function handler( + args: { + readonly name: string + readonly subcommand: Option.Option<{ readonly name: string }> + } + ) { + if (args.subcommand._tag === "Some" && TypeId in args.subcommand.value) { + return Effect.provideService( + handlers.get(args.subcommand.value[TypeId] as any)!(args.subcommand.value), + self.tag, + args as any + ) + } + return self.handler(args as any) + } + return HandledCommand(command as any, handler, self.tag) as any +}) /** * @since 1.0.0 From 9962fcd0823ee5136022ffd23362d06564ed5f10 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 12:52:30 +1300 Subject: [PATCH 10/39] parse config tuples --- examples/naval-fate.ts | 2 +- src/HandledCommand.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 844dbd7..6ff2b5b 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -101,7 +101,7 @@ const mineCommand = HandledCommand.withSubcommands(mineCommandParent, [ const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), - HandledCommand.fromCommandUnit, + HandledCommand.fromCommandRequestHelp, HandledCommand.withSubcommands([shipCommand, mineCommand]), HandledCommand.toAppAndRun({ name: "Naval Fate", diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index 0c707ea..6207e49 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -65,14 +65,16 @@ export declare namespace HandledCommand { * @category models */ export type ParseConfig = Types.Simplify< - { - readonly [Key in keyof A]: A[Key] extends Args.Args ? Value - : A[Key] extends Options.Options ? Value - : A[Key] extends ConfigBase ? ParseConfig - : never - } + { readonly [Key in keyof A]: ParseConfigValue } > + type ParseConfigValue = A extends ReadonlyArray ? + { readonly [Key in keyof A]: ParseConfigValue } : + A extends Args.Args ? Value + : A extends Options.Options ? Value + : A extends ConfigBase ? ParseConfig + : never + interface ParsedConfigTree { [key: string]: ParsedConfigNode } From 8175005362a72a18d39eb79aa8625d435b41601f Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 12:58:19 +1300 Subject: [PATCH 11/39] only add proxy if required --- src/HandledCommand.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index 6207e49..4b603c6 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -8,6 +8,7 @@ import { dual } from "effect/Function" import { globalValue } from "effect/GlobalValue" import type * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" +import * as Predicate from "effect/Predicate" import * as ReadonlyArray from "effect/ReadonlyArray" import type * as Types from "effect/Types" import * as Args from "./Args.js" @@ -209,17 +210,19 @@ const HandledCommand = ( ): HandledCommand => { const self = Object.create(Prototype) self.command = Command.map(command, (args) => - new Proxy(args as any, { - get(target, p, _receiver) { - if (p === TypeId) { - return self.tag + Predicate.hasProperty(args, TypeId) ? + args : + new Proxy(args as any, { + get(target, p, _receiver) { + if (p === TypeId) { + return self.tag + } + return target[p as any] + }, + has(target, p) { + return p === TypeId || p in target } - return target[p as any] - }, - has(target, p) { - return p === TypeId || p in target - } - })) + })) self.handler = handler self.tag = tag ?? Context.Tag() modifiedCommands.set(self.tag, self.command) From 18c4a38a500baba04ccecb00560716ab4f30f78a Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 27 Nov 2023 19:13:46 -0500 Subject: [PATCH 12/39] remove default for boolean option --- examples/naval-fate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 6ff2b5b..048319d 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -33,7 +33,7 @@ const speedOption = Options.integer("speed").pipe( ) const shipCommandParent = HandledCommand.makeRequestHelp("ship", { - verbose: Options.withDefault(Options.boolean("verbose"), false) + verbose: Options.boolean("verbose") }) const newShipCommand = HandledCommand.make("new", { From ee213434b5891a152c7bb8e532489cd73a06c88a Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 13:21:20 +1300 Subject: [PATCH 13/39] use name for context tracking --- examples/naval-fate.ts | 6 ++-- src/HandledCommand.ts | 81 +++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 048319d..f2c8041 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,7 +32,7 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const shipCommandParent = HandledCommand.makeRequestHelp("ship", { +const shipCommandParent = HandledCommand.makeHelp("ship", { verbose: Options.boolean("verbose") }) @@ -73,7 +73,7 @@ const shipCommand = HandledCommand.withSubcommands(shipCommandParent, [ shootShipCommand ]) -const mineCommandParent = HandledCommand.makeRequestHelp("mine") +const mineCommandParent = HandledCommand.makeHelp("mine") const setMineCommand = HandledCommand.make("set", { ...coordinatesArg, @@ -101,7 +101,7 @@ const mineCommand = HandledCommand.withSubcommands(mineCommandParent, [ const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), - HandledCommand.fromCommandRequestHelp, + HandledCommand.fromCommandHelp, HandledCommand.withSubcommands([shipCommand, mineCommand]), HandledCommand.toAppAndRun({ name: "Naval Fate", diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts index 4b603c6..d7644d3 100644 --- a/src/HandledCommand.ts +++ b/src/HandledCommand.ts @@ -35,13 +35,13 @@ export type TypeId = typeof TypeId * @since 1.0.0 * @category models */ -export interface HandledCommand - extends Pipeable, Effect.Effect, never, A> +export interface HandledCommand + extends Pipeable, Effect.Effect, never, A> { readonly [TypeId]: TypeId readonly command: Command.Command readonly handler: (_: A) => Effect.Effect - readonly tag: Context.Tag, A> + readonly tag: Context.Tag, A> } /** @@ -187,7 +187,7 @@ const reconstructConfigTree = ( const Prototype = { ...Effectable.CommitPrototype, [TypeId]: TypeId, - commit(this: HandledCommand) { + commit(this: HandledCommand) { return this.tag }, pipe() { @@ -200,14 +200,14 @@ const modifiedCommands = globalValue( () => new WeakMap, Command.Command>() ) -const getCommand = (self: HandledCommand) => +const getCommand = (self: HandledCommand) => modifiedCommands.get(self.tag) ?? self.command -const HandledCommand = ( +const HandledCommand = ( command: Command.Command, handler: (_: A) => Effect.Effect, tag?: Context.Tag -): HandledCommand => { +): HandledCommand => { const self = Object.create(Prototype) self.command = Command.map(command, (args) => Predicate.hasProperty(args, TypeId) ? @@ -234,31 +234,31 @@ const HandledCommand = ( * @category constructors */ export const fromCommand = dual< - ( + ( handler: (_: A) => Effect.Effect - ) => (command: Command.Command) => HandledCommand, - ( + ) => (command: Command.Command) => HandledCommand, + ( command: Command.Command, handler: (_: A) => Effect.Effect - ) => HandledCommand + ) => HandledCommand >(2, (command, handler) => HandledCommand(command, handler)) /** * @since 1.0.0 * @category constructors */ -export const fromCommandUnit = ( +export const fromCommandUnit = ( command: Command.Command -): HandledCommand => HandledCommand(command, (_) => Effect.unit) +): HandledCommand => HandledCommand(command, (_) => Effect.unit) /** * @since 1.0.0 * @category constructors */ -export const fromCommandRequestHelp = ( +export const fromCommandHelp = ( command: Command.Command -): HandledCommand => { - const self: HandledCommand = HandledCommand( +): HandledCommand => { + const self: HandledCommand = HandledCommand( command, (_) => Effect.fail(ValidationError.helpRequested(getCommand(self))) ) @@ -288,10 +288,11 @@ export const make = >) => Effect.Effect ): HandledCommand< + Name, Types.Simplify>, R, E -> => fromCommand(makeCommand(name, config), handler) +> => HandledCommand(makeCommand(name, config), handler) /** * @since 1.0.0 @@ -300,38 +301,46 @@ export const make = ( name: Name - ): HandledCommand<{}, never, never> + ): HandledCommand ( name: Name, config: Config ): HandledCommand< + Name, Types.Simplify>, never, never > -} = (name: string, config = {}) => fromCommandUnit(makeCommand(name, config)) +} = (name: string, config = {}) => fromCommandUnit(makeCommand(name, config) as any) as any /** * @since 1.0.0 * @category constructors */ -export const makeRequestHelp: { - (name: string): HandledCommand<{}, never, ValidationError.ValidationError> - (name: string, config: Config): HandledCommand< +export const makeHelp: { + ( + name: Name + ): HandledCommand + ( + name: Name, + config: Config + ): HandledCommand< + Name, Types.Simplify>, never, ValidationError.ValidationError > -} = (name: string, config = {}) => fromCommandRequestHelp(makeCommand(name, config)) as any +} = (name: string, config = {}) => fromCommandHelp(makeCommand(name, config) as any) as any /** * @since 1.0.0 * @category combinators */ export const withSubcommands = dual< - >>( + >>( subcommands: Subcommand - ) => (self: HandledCommand) => HandledCommand< + ) => (self: HandledCommand) => HandledCommand< + Name, Command.Command.ComputeParsedType< & A & Readonly< @@ -339,18 +348,23 @@ export const withSubcommands = dual< > >, | R - | Exclude>, Command.Command>, + | Exclude< + Effect.Effect.Context>, + Command.Command + >, E | Effect.Effect.Error> >, < + Name extends string, A, R, E, - Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> >( - self: HandledCommand, + self: HandledCommand, subcommands: Subcommand ) => HandledCommand< + Name, Command.Command.ComputeParsedType< & A & Readonly< @@ -358,7 +372,10 @@ export const withSubcommands = dual< > >, | R - | Exclude>, Command.Command>, + | Exclude< + Effect.Effect.Context>, + Command.Command + >, E | Effect.Effect.Error> > >(2, (self, subcommands) => { @@ -402,12 +419,12 @@ export const toAppAndRun = dual< readonly version: string readonly summary?: Span | undefined readonly footer?: HelpDoc | undefined - }) => ( - self: HandledCommand + }) => ( + self: HandledCommand ) => ( args: ReadonlyArray ) => Effect.Effect, - (self: HandledCommand, config: { + (self: HandledCommand, config: { readonly name: string readonly version: string readonly summary?: Span | undefined From 585b6027f9d15dd6934ae56bd31edce32a111a38 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 27 Nov 2023 19:49:39 -0500 Subject: [PATCH 14/39] fix boolean options wizard mode --- src/internal/command.ts | 1 + src/internal/options.ts | 3 +++ src/internal/primitive.ts | 5 +++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/internal/command.ts b/src/internal/command.ts index 80b2e9c..472bd25 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -896,6 +896,7 @@ const wizardInternal = ( ) } case "SubcommandWizard": { + console.log(self.parent) return Effect.zipWith( loop(self.parent), loop(self.child), diff --git a/src/internal/options.ts b/src/internal/options.ts index 4c2c788..2e2dc40 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1454,6 +1454,9 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. ) } case "WithDefault": { + if (isBoolInternal(self.options as Instruction)) { + return wizardInternal(self.options as Instruction, config) + } const defaultHelp = InternalHelpDoc.p(`This option is optional - use the default?`) const message = pipe( wizardHeader, diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 0cc0462..987cc44 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -504,12 +504,13 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt case "Bool": { const primitiveHelp = InternalHelpDoc.p("Select true or false") const message = InternalHelpDoc.sequence(help, primitiveHelp) + const initial = Option.getOrElse(self.defaultValue, () => false) return InternalTogglePrompt.toggle({ message: InternalHelpDoc.toAnsiText(message).trimEnd(), - initial: Option.getOrElse(self.defaultValue, () => false), + initial, active: "true", inactive: "false" - }).pipe(InternalPrompt.map((bool) => `${bool}`)) + }).pipe(InternalPrompt.map((bool) => bool)) } case "Choice": { const primitiveHelp = InternalHelpDoc.p("Select one of the following choices") From a6fe132bc92d554696719d5f9a9f0b9488dff0d3 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 15:38:30 +1300 Subject: [PATCH 15/39] rename HandledCommand to Command (#392) --- examples/minigit.ts | 136 +-- examples/naval-fate.ts | 27 +- examples/prompt.ts | 2 +- src/BuiltInOptions.ts | 2 +- src/CliApp.ts | 2 +- src/Command.ts | 371 +++---- src/CommandDescriptor.ts | 287 +++++ src/HandledCommand.ts | 441 -------- src/ValidationError.ts | 4 +- src/index.ts | 4 +- src/internal/builtInOptions.ts | 2 +- src/internal/cliApp.ts | 4 +- src/internal/command.ts | 1676 ++++++----------------------- src/internal/commandDescriptor.ts | 1434 ++++++++++++++++++++++++ 14 files changed, 2299 insertions(+), 2093 deletions(-) create mode 100644 src/CommandDescriptor.ts delete mode 100644 src/HandledCommand.ts create mode 100644 src/internal/commandDescriptor.ts diff --git a/examples/minigit.ts b/examples/minigit.ts index a936721..57c6c1f 100644 --- a/examples/minigit.ts +++ b/examples/minigit.ts @@ -1,124 +1,66 @@ import * as Args from "@effect/cli/Args" -import * as CliApp from "@effect/cli/CliApp" -import * as Command from "@effect/cli/Command" +import * as Handled from "@effect/cli/Command" import * as Options from "@effect/cli/Options" import * as NodeContext from "@effect/platform-node/NodeContext" import * as Runtime from "@effect/platform-node/Runtime" import * as Console from "effect/Console" -import * as Data from "effect/Data" import * as Effect from "effect/Effect" import { pipe } from "effect/Function" -import type * as HashMap from "effect/HashMap" import * as Option from "effect/Option" import * as ReadonlyArray from "effect/ReadonlyArray" -// ============================================================================= -// Models -// ============================================================================= - -type Subcommand = AddSubcommand | CloneSubcommand - -interface AddSubcommand extends Data.Case { - readonly _tag: "AddSubcommand" - readonly verbose: boolean -} -const AddSubcommand = Data.tagged("AddSubcommand") - -interface CloneSubcommand extends Data.Case { - readonly _tag: "CloneSubcommand" - readonly depth: Option.Option - readonly repository: string - readonly directory: Option.Option -} -const CloneSubcommand = Data.tagged("CloneSubcommand") - -// ============================================================================= -// Commands -// ============================================================================= - // minigit [--version] [-h | --help] [-c =] -const minigitOptions = Options.keyValueMap("c").pipe(Options.optional) -const minigit = Command.make("minigit", { options: minigitOptions }) +const minigit = Handled.make( + "minigit", + { configs: Options.keyValueMap("c").pipe(Options.optional) }, + ({ configs }) => + Option.match(configs, { + onNone: () => Console.log("Running 'minigit'"), + onSome: (configs) => { + const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join( + ", " + ) + return Console.log(`Running 'minigit' with the following configs: ${keyValuePairs}`) + } + }) +) // minigit add [-v | --verbose] [--] [...] -const minigitAddOptions = Options.boolean("verbose").pipe(Options.withAlias("v")) -const minigitAdd = Command.make("add", { options: minigitAddOptions }).pipe( - Command.map((parsed) => AddSubcommand({ verbose: parsed.options })) -) +const minigitAdd = Handled.make("add", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}, ({ verbose }) => Console.log(`Running 'minigit add' with '--verbose ${verbose}'`)) // minigit clone [--depth ] [--] [] -const minigitCloneArgs = Args.all([ - Args.text({ name: "repository" }), - Args.directory().pipe(Args.optional) -]) -const minigitCloneOptions = Options.integer("depth").pipe(Options.optional) -const minigitClone = Command.make("clone", { - options: minigitCloneOptions, - args: minigitCloneArgs -}).pipe(Command.map((parsed) => - CloneSubcommand({ - depth: parsed.options, - repository: parsed.args[0], - directory: parsed.args[1] - }) -)) +const minigitClone = Handled.make("clone", { + repository: Args.text({ name: "repository" }), + directory: Args.directory().pipe(Args.optional), + depth: Options.integer("depth").pipe(Options.optional) +}, ({ depth, directory, repository }) => { + const optionsAndArgs = pipe( + ReadonlyArray.compact([ + Option.map(depth, (depth) => `--depth ${depth}`), + Option.some(repository), + directory + ]), + ReadonlyArray.join(", ") + ) + return Console.log( + `Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'` + ) +}) -const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone])) +const finalCommand = minigit.pipe(Handled.withSubcommands([minigitAdd, minigitClone])) // ============================================================================= // Application // ============================================================================= -const cliApp = CliApp.make({ +const run = Handled.run(finalCommand, { name: "MiniGit Distributed Version Control", - version: "v2.42.1", - command: finalCommand -}) - -// ============================================================================= -// Execution -// ============================================================================= - -const handleRootCommand = (configs: Option.Option>) => - Option.match(configs, { - onNone: () => Console.log("Running 'minigit'"), - onSome: (configs) => { - const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join(", ") - return Console.log(`Running 'minigit' with the following configs: ${keyValuePairs}`) - } - }) - -const handleSubcommand = (subcommand: Subcommand) => { - switch (subcommand._tag) { - case "AddSubcommand": { - return Console.log(`Running 'minigit add' with '--verbose ${subcommand.verbose}'`) - } - case "CloneSubcommand": { - const optionsAndArgs = pipe( - ReadonlyArray.compact([ - Option.map(subcommand.depth, (depth) => `--depth ${depth}`), - Option.some(subcommand.repository), - subcommand.directory - ]), - ReadonlyArray.join(", ") - ) - return Console.log( - `Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'` - ) - } - } -} - -const program = Effect.gen(function*(_) { - const args = yield* _(Effect.sync(() => globalThis.process.argv.slice(2))) - return yield* _(CliApp.run(cliApp, args, (parsed) => - Option.match(parsed.subcommand, { - onNone: () => handleRootCommand(parsed.options), - onSome: (subcommand) => handleSubcommand(subcommand) - }))) + version: "v2.42.1" }) -program.pipe( +Effect.suspend(() => run(process.argv.slice(2))).pipe( Effect.provide(NodeContext.layer), Runtime.runMain ) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index f2c8041..11633ad 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -1,4 +1,4 @@ -import { Args, Command, HandledCommand, Options } from "@effect/cli" +import { Args, Command, Options } 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" @@ -32,11 +32,11 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const shipCommandParent = HandledCommand.makeHelp("ship", { +const shipCommandParent = Command.makeHelp("ship", { verbose: Options.boolean("verbose") }) -const newShipCommand = HandledCommand.make("new", { +const newShipCommand = Command.make("new", { name: nameArg }, ({ name }) => Effect.gen(function*(_) { @@ -48,7 +48,7 @@ const newShipCommand = HandledCommand.make("new", { } })) -const moveShipCommand = HandledCommand.make("move", { +const moveShipCommand = Command.make("move", { ...nameAndCoordinatesArg, speed: speedOption }, ({ name, speed, x, y }) => @@ -57,7 +57,7 @@ const moveShipCommand = HandledCommand.make("move", { yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`)) })) -const shootShipCommand = HandledCommand.make( +const shootShipCommand = Command.make( "shoot", { ...coordinatesArg }, ({ x, y }) => @@ -67,15 +67,15 @@ const shootShipCommand = HandledCommand.make( }) ) -const shipCommand = HandledCommand.withSubcommands(shipCommandParent, [ +const shipCommand = Command.withSubcommands(shipCommandParent, [ newShipCommand, moveShipCommand, shootShipCommand ]) -const mineCommandParent = HandledCommand.makeHelp("mine") +const mineCommandParent = Command.makeHelp("mine") -const setMineCommand = HandledCommand.make("set", { +const setMineCommand = Command.make("set", { ...coordinatesArg, moored: mooredOption }, ({ moored, x, y }) => @@ -86,7 +86,7 @@ const setMineCommand = HandledCommand.make("set", { ) })) -const removeMineCommand = HandledCommand.make("remove", { +const removeMineCommand = Command.make("remove", { ...coordinatesArg }, ({ x, y }) => Effect.gen(function*(_) { @@ -94,16 +94,15 @@ const removeMineCommand = HandledCommand.make("remove", { yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) })) -const mineCommand = HandledCommand.withSubcommands(mineCommandParent, [ +const mineCommand = Command.withSubcommands(mineCommandParent, [ setMineCommand, removeMineCommand ]) -const run = Command.make("naval_fate").pipe( +const run = Command.makeHelp("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), - HandledCommand.fromCommandHelp, - HandledCommand.withSubcommands([shipCommand, mineCommand]), - HandledCommand.toAppAndRun({ + Command.withSubcommands([shipCommand, mineCommand]), + Command.run({ name: "Naval Fate", version: "1.0.0" }) diff --git a/examples/prompt.ts b/examples/prompt.ts index f1d95b6..4f2e450 100644 --- a/examples/prompt.ts +++ b/examples/prompt.ts @@ -1,5 +1,5 @@ import * as CliApp from "@effect/cli/CliApp" -import * as Command from "@effect/cli/Command" +import * as Command from "@effect/cli/CommandDescriptor" import * as Prompt from "@effect/cli/Prompt" import * as NodeContext from "@effect/platform-node/NodeContext" import * as Runtime from "@effect/platform-node/Runtime" diff --git a/src/BuiltInOptions.ts b/src/BuiltInOptions.ts index bda5ebd..1225123 100644 --- a/src/BuiltInOptions.ts +++ b/src/BuiltInOptions.ts @@ -3,7 +3,7 @@ */ import type { Option } from "effect/Option" -import type { Command } from "./Command.js" +import type { Command } from "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" import * as InternalBuiltInOptions from "./internal/builtInOptions.js" import type { Options } from "./Options.js" diff --git a/src/CliApp.ts b/src/CliApp.ts index 69651c2..741dc06 100644 --- a/src/CliApp.ts +++ b/src/CliApp.ts @@ -7,7 +7,7 @@ import type { Path } from "@effect/platform/Path" import type { Terminal } from "@effect/platform/Terminal" import type { Effect } from "effect/Effect" import type { Pipeable } from "effect/Pipeable" -import type { Command } from "./Command.js" +import type { Command } from "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" import type { Span } from "./HelpDoc/Span.js" import * as InternalCliApp from "./internal/cliApp.js" diff --git a/src/Command.ts b/src/Command.ts index 8cd5ff2..0d09606 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,289 +1,296 @@ /** * @since 1.0.0 */ -import type { FileSystem } from "@effect/platform/FileSystem" -import type { Terminal } from "@effect/platform/Terminal" +import type { Tag } from "effect/Context" import type { Effect } from "effect/Effect" -import type { Either } from "effect/Either" -import type { HashMap } from "effect/HashMap" -import type { HashSet } from "effect/HashSet" import type { Option } from "effect/Option" -import type { Pipeable } from "effect/Pipeable" -import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import { type Pipeable } from "effect/Pipeable" +import type * as Types from "effect/Types" import type { Args } from "./Args.js" -import type { CliConfig } from "./CliConfig.js" -import type { CommandDirective } from "./CommandDirective.js" +import type { CliApp } from "./CliApp.js" +import type * as Descriptor from "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" -import * as InternalCommand from "./internal/command.js" +import type { Span } from "./HelpDoc/Span.js" +import * as Internal from "./internal/command.js" import type { Options } from "./Options.js" import type { Prompt } from "./Prompt.js" -import type { Usage } from "./Usage.js" import type { ValidationError } from "./ValidationError.js" /** * @since 1.0.0 - * @category symbols + * @category type ids */ -export const CommandTypeId: unique symbol = InternalCommand.CommandTypeId +export const TypeId: unique symbol = Internal.TypeId /** * @since 1.0.0 - * @category symbols + * @category type ids */ -export type CommandTypeId = typeof CommandTypeId +export type TypeId = typeof TypeId /** - * A `Command` represents a command in a command-line application. - * - * Every command-line application will have at least one command: the - * application itself. Other command-line applications may support multiple - * commands. - * * @since 1.0.0 * @category models */ -export interface Command extends Command.Variance, Pipeable {} +export interface Command + extends Pipeable, Effect, never, A> +{ + readonly [TypeId]: TypeId + readonly descriptor: Descriptor.Command + readonly handler: (_: A) => Effect + readonly tag: Tag, A> +} /** * @since 1.0.0 + * @category models */ export declare namespace Command { /** * @since 1.0.0 * @category models */ - export interface Variance { - readonly [CommandTypeId]: { - readonly _A: (_: never) => A - } + export interface ConfigBase { + readonly [key: string]: + | Args + | Options + | ReadonlyArray | Options | ConfigBase> + | ConfigBase } /** * @since 1.0.0 * @category models */ - export interface ConstructorConfig { - readonly options?: Options - readonly args?: Args - } - - /** - * @since 1.0.0 - * @category models - */ - export type ParsedStandardCommand = - Command.ComputeParsedType<{ - readonly name: Name - readonly options: OptionsType - readonly args: ArgsType - }> - - /** - * @since 1.0.0 - * @category models - */ - export type ParsedUserInputCommand = Command.ComputeParsedType<{ - readonly name: Name - readonly value: ValueType - }> + export type ParseConfig = Types.Simplify< + { readonly [Key in keyof A]: ParseConfigValue } + > - /** - * @since 1.0.0 - * @category models - */ - export type ParsedSubcommand> = A[number] extends - Command ? GetParsedType + type ParseConfigValue = A extends ReadonlyArray ? + { readonly [Key in keyof A]: ParseConfigValue } : + A extends Args ? Value + : A extends Options ? Value + : A extends ConfigBase ? ParseConfig : never - /** - * @since 1.0.0 - * @category models - */ - export type GetParsedType = C extends Command ? P : never + interface ParsedConfigTree { + [key: string]: ParsedConfigNode + } - /** - * @since 1.0.0 - * @category models - */ - export type ComputeParsedType = { [K in keyof A]: A[K] } extends infer X ? X : never + type ParsedConfigNode = { + readonly _tag: "Args" + readonly index: number + } | { + readonly _tag: "Options" + readonly index: number + } | { + readonly _tag: "Array" + readonly children: ReadonlyArray + } | { + readonly _tag: "ParsedConfig" + readonly tree: ParsedConfigTree + } /** * @since 1.0.0 * @category models */ - export type Subcommands>> = GetParsedType + export interface ParsedConfig { + readonly args: ReadonlyArray> + readonly options: ReadonlyArray> + readonly tree: ParsedConfigTree + } } /** * @since 1.0.0 - * @category combinators - */ -export const getHelp: (self: Command) => HelpDoc = InternalCommand.getHelp - -/** - * @since 1.0.0 - * @category combinators - */ -export const getBashCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getBashCompletions - -/** - * @since 1.0.0 - * @category combinators - */ -export const getFishCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getFishCompletions - -/** - * @since 1.0.0 - * @category combinators - */ -export const getZshCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getZshCompletions - -/** - * @since 1.0.0 - * @category combinators - */ -export const getNames: (self: Command) => HashSet = InternalCommand.getNames - -/** - * @since 1.0.0 - * @category combinators + * @category constructors */ -export const getSubcommands: (self: Command) => HashMap> = - InternalCommand.getSubcommands +export const fromDescriptor: { + ( + handler: (_: A) => Effect + ): (command: Descriptor.Command) => Command + ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect + ): Command +} = Internal.fromDescriptor /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const getUsage: (self: Command) => Usage = InternalCommand.getUsage +export const fromDescriptorHelp: ( + descriptor: Descriptor.Command +) => Command = Internal.fromDescriptorHelp /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const map: { - (f: (a: A) => B): (self: Command) => Command - (self: Command, f: (a: A) => B): Command -} = InternalCommand.map +export const fromDescriptorUnit: ( + descriptor: Descriptor.Command +) => Command = Internal.fromDescriptorUnit /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const mapOrFail: { - (f: (a: A) => Either): (self: Command) => Command - (self: Command, f: (a: A) => Either): Command -} = InternalCommand.mapOrFail +export const make: ( + name: Name, + config: Config, + handler: (_: Types.Simplify>) => Effect +) => Command< + Name, + R, + E, + Types.Simplify> +> = Internal.make /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const orElse: { - (that: Command): (self: Command) => Command - (self: Command, that: Command): Command -} = InternalCommand.orElse +export const makeHelp: { + (name: Name): Command + ( + name: Name, + config: Config + ): Command< + Name, + never, + ValidationError, + Types.Simplify< + Types.Simplify<{ readonly [Key in keyof Config]: Command.ParseConfigValue }> + > + > +} = Internal.makeHelp /** * @since 1.0.0 - * @category combinators + * @category constructors */ -export const orElseEither: { - (that: Command): (self: Command) => Command> - (self: Command, that: Command): Command> -} = InternalCommand.orElseEither +export const makeUnit: { + (name: Name): Command + ( + name: Name, + config: Config + ): Command< + Name, + never, + never, + Types.Simplify< + Types.Simplify<{ readonly [Key in keyof Config]: Command.ParseConfigValue }> + > + > +} = Internal.makeUnit /** * @since 1.0.0 * @category combinators */ -export const parse: { - ( - args: ReadonlyArray, - config: CliConfig - ): ( - self: Command - ) => Effect> +export const mapDescriptor: { ( - self: Command, - args: ReadonlyArray, - config: CliConfig - ): Effect> -} = InternalCommand.parse + f: (_: Descriptor.Command) => Descriptor.Command + ): (self: Command) => Command + ( + self: Command, + f: (_: Descriptor.Command) => Descriptor.Command + ): Command +} = Internal.mapDescriptor /** * @since 1.0.0 * @category constructors */ -export const prompt: ( +export const prompt: ( name: Name, - prompt: Prompt -) => Command<{ readonly name: Name; readonly value: A }> = InternalCommand.prompt - -/** - * @since 1.0.0 - * @category constructors - */ -export const make: ( - name: Name, - config?: Command.ConstructorConfig -) => Command<{ readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }> = - InternalCommand.make + prompt: Prompt, + handler: (_: A) => Effect +) => Command = Internal.prompt /** * @since 1.0.0 * @category combinators */ export const withDescription: { - (description: string | HelpDoc): (self: Command) => Command - (self: Command, description: string | HelpDoc): Command -} = InternalCommand.withDescription + ( + help: string | HelpDoc + ): (self: Command) => Command + ( + self: Command, + help: string | HelpDoc + ): Command +} = Internal.withDescription /** * @since 1.0.0 * @category combinators */ export const withSubcommands: { - >>( - subcommands: [...Subcommands] - ): ( - self: Command + < + Subcommand extends readonly [Command, ...Array>] + >( + subcommands: Subcommand + ): ( + self: Command ) => Command< - Command.ComputeParsedType< - A & Readonly<{ subcommand: Option> }> + Name, + | R + | Exclude>, Descriptor.Command>, + E | Effect.Error>, + Descriptor.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option> } + > > > - >>( - self: Command, - subcommands: [...Subcommands] + < + Name extends string, + R, + E, + A, + Subcommand extends readonly [Command, ...Array>] + >( + self: Command, + subcommands: Subcommand ): Command< - Command.ComputeParsedType< - A & Readonly<{ subcommand: Option> }> + Name, + | R + | Exclude>, Descriptor.Command>, + E | Effect.Error>, + Descriptor.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option> } + > > > -} = InternalCommand.withSubcommands +} = Internal.withSubcommands /** * @since 1.0.0 - * @category combinators + * @category conversions */ -export const wizard: { +export const run: { ( - config: CliConfig - ): ( - self: Command - ) => Effect> - ( - self: Command, - config: CliConfig - ): Effect> -} = InternalCommand.wizard + config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + } + ): ( + self: Command + ) => (args: ReadonlyArray) => Effect + ( + self: Command, + config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + } + ): (args: ReadonlyArray) => Effect +} = Internal.run diff --git a/src/CommandDescriptor.ts b/src/CommandDescriptor.ts new file mode 100644 index 0000000..5b57fc7 --- /dev/null +++ b/src/CommandDescriptor.ts @@ -0,0 +1,287 @@ +/** + * @since 1.0.0 + */ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Terminal } from "@effect/platform/Terminal" +import type { Effect } from "effect/Effect" +import type { Either } from "effect/Either" +import type { HashMap } from "effect/HashMap" +import type { HashSet } from "effect/HashSet" +import type { Option } from "effect/Option" +import type { Pipeable } from "effect/Pipeable" +import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import type { Args } from "./Args.js" +import type { CliConfig } from "./CliConfig.js" +import type { CommandDirective } from "./CommandDirective.js" +import type { HelpDoc } from "./HelpDoc.js" +import * as Internal from "./internal/commandDescriptor.js" +import type { Options } from "./Options.js" +import type { Prompt } from "./Prompt.js" +import type { Usage } from "./Usage.js" +import type { ValidationError } from "./ValidationError.js" + +/** + * @since 1.0.0 + * @category symbols + */ +export const TypeId: unique symbol = Internal.TypeId + +/** + * @since 1.0.0 + * @category symbols + */ +export type TypeId = typeof TypeId + +/** + * A `Command` represents a command in a command-line application. + * + * Every command-line application will have at least one command: the + * application itself. Other command-line applications may support multiple + * commands. + * + * @since 1.0.0 + * @category models + */ +export interface Command extends Command.Variance, Pipeable {} + +/** + * @since 1.0.0 + */ +export declare namespace Command { + /** + * @since 1.0.0 + * @category models + */ + export interface Variance { + readonly [TypeId]: { + readonly _A: (_: never) => A + } + } + + /** + * @since 1.0.0 + * @category models + */ + export type ParsedStandardCommand = + Command.ComputeParsedType<{ + readonly name: Name + readonly options: OptionsType + readonly args: ArgsType + }> + + /** + * @since 1.0.0 + * @category models + */ + export type ParsedUserInputCommand = Command.ComputeParsedType<{ + readonly name: Name + readonly value: ValueType + }> + + /** + * @since 1.0.0 + * @category models + */ + export type GetParsedType = C extends Command ? P : never + + /** + * @since 1.0.0 + * @category models + */ + export type ComputeParsedType = { [K in keyof A]: A[K] } extends infer X ? X : never + + /** + * @since 1.0.0 + * @category models + */ + export type Subcommands< + A extends NonEmptyReadonlyArray]> + > = A[number] extends readonly [infer Id, Command] ? readonly [id: Id, value: Value] + : never +} + +/** + * @since 1.0.0 + * @category combinators + */ +export const getHelp: (self: Command) => HelpDoc = Internal.getHelp + +/** + * @since 1.0.0 + * @category combinators + */ +export const getBashCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getBashCompletions + +/** + * @since 1.0.0 + * @category combinators + */ +export const getFishCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getFishCompletions + +/** + * @since 1.0.0 + * @category combinators + */ +export const getZshCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getZshCompletions + +/** + * @since 1.0.0 + * @category combinators + */ +export const getNames: (self: Command) => HashSet = Internal.getNames + +/** + * @since 1.0.0 + * @category combinators + */ +export const getSubcommands: (self: Command) => HashMap> = + Internal.getSubcommands + +/** + * @since 1.0.0 + * @category combinators + */ +export const getUsage: (self: Command) => Usage = Internal.getUsage + +/** + * @since 1.0.0 + * @category combinators + */ +export const map: { + (f: (a: A) => B): (self: Command) => Command + (self: Command, f: (a: A) => B): Command +} = Internal.map + +/** + * @since 1.0.0 + * @category combinators + */ +export const mapOrFail: { + (f: (a: A) => Either): (self: Command) => Command + (self: Command, f: (a: A) => Either): Command +} = Internal.mapOrFail + +/** + * @since 1.0.0 + * @category combinators + */ +export const orElse: { + (that: Command): (self: Command) => Command + (self: Command, that: Command): Command +} = Internal.orElse + +/** + * @since 1.0.0 + * @category combinators + */ +export const orElseEither: { + (that: Command): (self: Command) => Command> + (self: Command, that: Command): Command> +} = Internal.orElseEither + +/** + * @since 1.0.0 + * @category combinators + */ +export const parse: { + ( + args: ReadonlyArray, + config: CliConfig + ): ( + self: Command + ) => Effect> + ( + self: Command, + args: ReadonlyArray, + config: CliConfig + ): Effect> +} = Internal.parse + +/** + * @since 1.0.0 + * @category constructors + */ +export const prompt: ( + name: Name, + prompt: Prompt +) => Command<{ readonly name: Name; readonly value: A }> = Internal.prompt + +/** + * @since 1.0.0 + * @category constructors + */ +export const make: ( + name: Name, + options?: Options, + args?: Args +) => Command<{ readonly name: Name; readonly options: OptionsType; readonly args: ArgsType }> = + Internal.make + +/** + * @since 1.0.0 + * @category combinators + */ +export const withDescription: { + (description: string | HelpDoc): (self: Command) => Command + (self: Command, description: string | HelpDoc): Command +} = Internal.withDescription + +/** + * @since 1.0.0 + * @category combinators + */ +export const withSubcommands: { + < + Subcommands extends readonly [ + readonly [id: unknown, command: Command], + ...Array]> + ] + >( + subcommands: [...Subcommands] + ): ( + self: Command + ) => Command< + Command.ComputeParsedType< + A & Readonly<{ subcommand: Option> }> + > + > + < + A, + Subcommands extends readonly [ + readonly [id: unknown, command: Command], + ...Array]> + ] + >( + self: Command, + subcommands: [...Subcommands] + ): Command< + Command.ComputeParsedType< + A & Readonly<{ subcommand: Option> }> + > + > +} = Internal.withSubcommands + +/** + * @since 1.0.0 + * @category combinators + */ +export const wizard: { + ( + config: CliConfig + ): ( + self: Command + ) => Effect> + ( + self: Command, + config: CliConfig + ): Effect> +} = Internal.wizard diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts deleted file mode 100644 index d7644d3..0000000 --- a/src/HandledCommand.ts +++ /dev/null @@ -1,441 +0,0 @@ -/** - * @since 1.0.0 - */ -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Effectable from "effect/Effectable" -import { dual } from "effect/Function" -import { globalValue } from "effect/GlobalValue" -import type * as Option from "effect/Option" -import { type Pipeable, pipeArguments } from "effect/Pipeable" -import * as Predicate from "effect/Predicate" -import * as ReadonlyArray from "effect/ReadonlyArray" -import type * as Types from "effect/Types" -import * as Args from "./Args.js" -import * as CliApp from "./CliApp.js" -import * as Command from "./Command.js" -import type { HelpDoc } from "./HelpDoc.js" -import type { Span } from "./HelpDoc/Span.js" -import * as Options from "./Options.js" -import * as ValidationError from "./ValidationError.js" - -/** - * @since 1.0.0 - * @category type ids - */ -export const TypeId = Symbol.for("@effect/cli/HandledCommand") - -/** - * @since 1.0.0 - * @category type ids - */ -export type TypeId = typeof TypeId - -/** - * @since 1.0.0 - * @category models - */ -export interface HandledCommand - extends Pipeable, Effect.Effect, never, A> -{ - readonly [TypeId]: TypeId - readonly command: Command.Command - readonly handler: (_: A) => Effect.Effect - readonly tag: Context.Tag, A> -} - -/** - * @since 1.0.0 - * @category models - */ -export declare namespace HandledCommand { - /** - * @since 1.0.0 - * @category models - */ - export interface ConfigBase { - readonly [key: string]: - | Args.Args - | Options.Options - | ReadonlyArray | Options.Options | ConfigBase> - | ConfigBase - } - - /** - * @since 1.0.0 - * @category models - */ - export type ParseConfig = Types.Simplify< - { readonly [Key in keyof A]: ParseConfigValue } - > - - type ParseConfigValue = A extends ReadonlyArray ? - { readonly [Key in keyof A]: ParseConfigValue } : - A extends Args.Args ? Value - : A extends Options.Options ? Value - : A extends ConfigBase ? ParseConfig - : never - - interface ParsedConfigTree { - [key: string]: ParsedConfigNode - } - - type ParsedConfigNode = { - readonly _tag: "Args" - readonly index: number - } | { - readonly _tag: "Options" - readonly index: number - } | { - readonly _tag: "Array" - readonly children: ReadonlyArray - } | { - readonly _tag: "ParsedConfig" - readonly tree: ParsedConfigTree - } - - /** - * @since 1.0.0 - * @category models - */ - export interface ParsedConfig { - readonly args: ReadonlyArray> - readonly options: ReadonlyArray> - readonly tree: ParsedConfigTree - } -} - -const parseConfig = (config: HandledCommand.ConfigBase): HandledCommand.ParsedConfig => { - const args: Array> = [] - let argsIndex = 0 - const options: Array> = [] - let optionsIndex = 0 - const tree: HandledCommand.ParsedConfigTree = {} - - function parse(config: HandledCommand.ConfigBase) { - for (const key in config) { - tree[key] = parseValue(config[key]) - } - return tree - } - - function parseValue( - value: - | Args.Args - | Options.Options - | ReadonlyArray | Options.Options | HandledCommand.ConfigBase> - | HandledCommand.ConfigBase - ): HandledCommand.ParsedConfigNode { - if (Array.isArray(value)) { - return { - _tag: "Array", - children: ReadonlyArray.map(value, parseValue) - } - } else if (Args.isArgs(value)) { - args.push(value) - return { - _tag: "Args", - index: argsIndex++ - } - } else if (Options.isOptions(value)) { - options.push(value) - return { - _tag: "Options", - index: optionsIndex++ - } - } else { - return { - _tag: "ParsedConfig", - tree: parse(value as any) - } - } - } - - return { - args, - options, - tree: parse(config) - } -} - -const reconstructConfigTree = ( - tree: HandledCommand.ParsedConfigTree, - args: ReadonlyArray, - options: ReadonlyArray -): Record => { - const output: Record = {} - - for (const key in tree) { - output[key] = nodeValue(tree[key]) - } - - return output - - function nodeValue(node: HandledCommand.ParsedConfigNode): any { - if (node._tag === "Args") { - return args[node.index] - } else if (node._tag === "Options") { - return options[node.index] - } else if (node._tag === "Array") { - return ReadonlyArray.map(node.children, nodeValue) - } else { - return reconstructConfigTree(node.tree, args, options) - } - } -} - -const Prototype = { - ...Effectable.CommitPrototype, - [TypeId]: TypeId, - commit(this: HandledCommand) { - return this.tag - }, - pipe() { - return pipeArguments(this, arguments) - } -} - -const modifiedCommands = globalValue( - "@effect/cli/HandledCommand/modifiedCommands", - () => new WeakMap, Command.Command>() -) - -const getCommand = (self: HandledCommand) => - modifiedCommands.get(self.tag) ?? self.command - -const HandledCommand = ( - command: Command.Command, - handler: (_: A) => Effect.Effect, - tag?: Context.Tag -): HandledCommand => { - const self = Object.create(Prototype) - self.command = Command.map(command, (args) => - Predicate.hasProperty(args, TypeId) ? - args : - new Proxy(args as any, { - get(target, p, _receiver) { - if (p === TypeId) { - return self.tag - } - return target[p as any] - }, - has(target, p) { - return p === TypeId || p in target - } - })) - self.handler = handler - self.tag = tag ?? Context.Tag() - modifiedCommands.set(self.tag, self.command) - return self -} - -/** - * @since 1.0.0 - * @category constructors - */ -export const fromCommand = dual< - ( - handler: (_: A) => Effect.Effect - ) => (command: Command.Command) => HandledCommand, - ( - command: Command.Command, - handler: (_: A) => Effect.Effect - ) => HandledCommand ->(2, (command, handler) => HandledCommand(command, handler)) - -/** - * @since 1.0.0 - * @category constructors - */ -export const fromCommandUnit = ( - command: Command.Command -): HandledCommand => HandledCommand(command, (_) => Effect.unit) - -/** - * @since 1.0.0 - * @category constructors - */ -export const fromCommandHelp = ( - command: Command.Command -): HandledCommand => { - const self: HandledCommand = HandledCommand( - command, - (_) => Effect.fail(ValidationError.helpRequested(getCommand(self))) - ) - return self -} - -const makeCommand = ( - name: string, - config: Config -): Command.Command>> => { - const { args, options, tree } = parseConfig(config) - return Command.map( - Command.make(name, { - args: Args.all(args), - options: Options.all(options) - }), - ({ args, options }) => reconstructConfigTree(tree, args, options) - ) as any -} - -/** - * @since 1.0.0 - * @category constructors - */ -export const make = ( - name: Name, - config: Config, - handler: (_: Types.Simplify>) => Effect.Effect -): HandledCommand< - Name, - Types.Simplify>, - R, - E -> => HandledCommand(makeCommand(name, config), handler) - -/** - * @since 1.0.0 - * @category constructors - */ -export const makeUnit: { - ( - name: Name - ): HandledCommand - ( - name: Name, - config: Config - ): HandledCommand< - Name, - Types.Simplify>, - never, - never - > -} = (name: string, config = {}) => fromCommandUnit(makeCommand(name, config) as any) as any - -/** - * @since 1.0.0 - * @category constructors - */ -export const makeHelp: { - ( - name: Name - ): HandledCommand - ( - name: Name, - config: Config - ): HandledCommand< - Name, - Types.Simplify>, - never, - ValidationError.ValidationError - > -} = (name: string, config = {}) => fromCommandHelp(makeCommand(name, config) as any) as any - -/** - * @since 1.0.0 - * @category combinators - */ -export const withSubcommands = dual< - >>( - subcommands: Subcommand - ) => (self: HandledCommand) => HandledCommand< - Name, - Command.Command.ComputeParsedType< - & A - & Readonly< - { subcommand: Option.Option> } - > - >, - | R - | Exclude< - Effect.Effect.Context>, - Command.Command - >, - E | Effect.Effect.Error> - >, - < - Name extends string, - A, - R, - E, - Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> - >( - self: HandledCommand, - subcommands: Subcommand - ) => HandledCommand< - Name, - Command.Command.ComputeParsedType< - & A - & Readonly< - { subcommand: Option.Option> } - > - >, - | R - | Exclude< - Effect.Effect.Context>, - Command.Command - >, - E | Effect.Effect.Error> - > ->(2, (self, subcommands) => { - const command = Command.withSubcommands( - self.command, - ReadonlyArray.map(subcommands, (_) => _.command) - ) - const handlers = ReadonlyArray.reduce( - subcommands, - new Map, (_: any) => Effect.Effect>(), - (handlers, subcommand) => { - handlers.set(subcommand.tag, subcommand.handler) - return handlers - } - ) - function handler( - args: { - readonly name: string - readonly subcommand: Option.Option<{ readonly name: string }> - } - ) { - if (args.subcommand._tag === "Some" && TypeId in args.subcommand.value) { - return Effect.provideService( - handlers.get(args.subcommand.value[TypeId] as any)!(args.subcommand.value), - self.tag, - args as any - ) - } - return self.handler(args as any) - } - return HandledCommand(command as any, handler, self.tag) as any -}) - -/** - * @since 1.0.0 - * @category combinators - */ -export const toAppAndRun = dual< - (config: { - readonly name: string - readonly version: string - readonly summary?: Span | undefined - readonly footer?: HelpDoc | undefined - }) => ( - self: HandledCommand - ) => ( - args: ReadonlyArray - ) => Effect.Effect, - (self: HandledCommand, config: { - readonly name: string - readonly version: string - readonly summary?: Span | undefined - readonly footer?: HelpDoc | undefined - }) => ( - args: ReadonlyArray - ) => Effect.Effect ->(2, (self, config) => { - const app = CliApp.make({ - ...config, - command: self.command - }) - return (args) => CliApp.run(app, args, self.handler) -}) diff --git a/src/ValidationError.ts b/src/ValidationError.ts index 4923a5d..a3e6dee 100644 --- a/src/ValidationError.ts +++ b/src/ValidationError.ts @@ -2,9 +2,9 @@ * @since 1.0.0 */ import type { BuiltInOptions } from "./BuiltInOptions.js" -import type { Command } from "./Command.js" +import type { Command } from "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" -import * as InternalCommand from "./internal/command.js" +import * as InternalCommand from "./internal/commandDescriptor.js" import * as InternalValidationError from "./internal/validationError.js" /** diff --git a/src/index.ts b/src/index.ts index f41ef51..3688e12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,12 +31,12 @@ export * as Command from "./Command.js" /** * @since 1.0.0 */ -export * as CommandDirective from "./CommandDirective.js" +export * as CommandDescriptor from "./CommandDescriptor.js" /** * @since 1.0.0 */ -export * as HandledCommand from "./HandledCommand.js" +export * as CommandDirective from "./CommandDirective.js" /** * @since 1.0.0 diff --git a/src/internal/builtInOptions.ts b/src/internal/builtInOptions.ts index cbdff68..0c7685e 100644 --- a/src/internal/builtInOptions.ts +++ b/src/internal/builtInOptions.ts @@ -1,6 +1,6 @@ import * as Option from "effect/Option" import type * as BuiltInOptions from "../BuiltInOptions.js" -import type * as Command from "../Command.js" +import type * as Command from "../CommandDescriptor.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Options from "../Options.js" import type * as Usage from "../Usage.js" diff --git a/src/internal/cliApp.ts b/src/internal/cliApp.ts index 8378639..d3699ca 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -9,12 +9,12 @@ import * as ReadonlyArray from "effect/ReadonlyArray" import type * as BuiltInOptions from "../BuiltInOptions.js" import type * as CliApp from "../CliApp.js" import type * as CliConfig from "../CliConfig.js" -import type * as Command from "../Command.js" +import type * as Command from "../CommandDescriptor.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Span from "../HelpDoc/Span.js" import type * as ValidationError from "../ValidationError.js" import * as InternalCliConfig from "./cliConfig.js" -import * as InternalCommand from "./command.js" +import * as InternalCommand from "./commandDescriptor.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalUsage from "./usage.js" diff --git a/src/internal/command.ts b/src/internal/command.ts index 472bd25..bb97a76 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,1409 +1,387 @@ -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Terminal from "@effect/platform/Terminal" -import * as Console from "effect/Console" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" -import * as Either from "effect/Either" -import { dual, pipe } from "effect/Function" -import * as HashMap from "effect/HashMap" -import * as HashSet from "effect/HashSet" -import * as Option from "effect/Option" -import * as Order from "effect/Order" +import * as Effectable from "effect/Effectable" +import { dual } from "effect/Function" +import { globalValue } from "effect/GlobalValue" +import type * as Option from "effect/Option" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" -import * as SynchronizedRef from "effect/SynchronizedRef" +import type * as Types from "effect/Types" import type * as Args from "../Args.js" -import type * as CliConfig from "../CliConfig.js" +import type * as CliApp from "../CliApp.js" import type * as Command from "../Command.js" -import type * as CommandDirective from "../CommandDirective.js" -import * as HelpDoc from "../HelpDoc.js" -import type * as Span from "../HelpDoc/Span.js" -import * as Options from "../Options.js" +import type * as Descriptor from "../CommandDescriptor.js" +import type { HelpDoc } from "../HelpDoc.js" +import type { Span } from "../HelpDoc/Span.js" +import type * as Options from "../Options.js" import type * as Prompt from "../Prompt.js" -import type * as Usage from "../Usage.js" -import type * as ValidationError from "../ValidationError.js" +import * as ValidationError from "../ValidationError.js" import * as InternalArgs from "./args.js" -import * as InternalBuiltInOptions from "./builtInOptions.js" -import * as InternalCliConfig from "./cliConfig.js" -import * as InternalCommandDirective from "./commandDirective.js" -import * as InternalHelpDoc from "./helpDoc.js" -import * as InternalSpan from "./helpDoc/span.js" +import * as InternalCliApp from "./cliApp.js" +import * as InternalDescriptor from "./commandDescriptor.js" import * as InternalOptions from "./options.js" -import * as InternalPrompt from "./prompt.js" -import * as InternalSelectPrompt from "./prompt/select.js" -import * as InternalUsage from "./usage.js" -import * as InternalValidationError from "./validationError.js" const CommandSymbolKey = "@effect/cli/Command" /** @internal */ -export const CommandTypeId: Command.CommandTypeId = Symbol.for( +export const TypeId: Command.TypeId = Symbol.for( CommandSymbolKey -) as Command.CommandTypeId +) as Command.TypeId -/** @internal */ -export type Op = Command.Command & Body & { - readonly _tag: Tag -} +const parseConfig = (config: Command.Command.ConfigBase): Command.Command.ParsedConfig => { + const args: Array> = [] + let argsIndex = 0 + const options: Array> = [] + let optionsIndex = 0 + const tree: Command.Command.ParsedConfigTree = {} -const proto = { - [CommandTypeId]: { - _A: (_: never) => _ - }, - pipe() { - return pipeArguments(this, arguments) + function parse(config: Command.Command.ConfigBase) { + for (const key in config) { + tree[key] = parseValue(config[key]) + } + return tree } -} - -/** @internal */ -export type Instruction = - | Standard - | GetUserInput - | Map - | OrElse - | Subcommands - -/** @internal */ -export interface Standard extends - Op<"Standard", { - readonly name: string - readonly description: HelpDoc.HelpDoc - readonly options: Options.Options - readonly args: Args.Args - }> -{} - -/** @internal */ -export interface GetUserInput extends - Op<"GetUserInput", { - readonly name: string - readonly description: HelpDoc.HelpDoc - readonly prompt: Prompt.Prompt - }> -{} -/** @internal */ -export interface Map extends - Op<"Map", { - readonly command: Command.Command - readonly f: (value: unknown) => Either.Either - }> -{} - -/** @internal */ -export interface OrElse extends - Op<"OrElse", { - readonly left: Command.Command - readonly right: Command.Command - }> -{} - -/** @internal */ -export interface Subcommands extends - Op<"Subcommands", { - readonly parent: Command.Command - readonly child: Command.Command - }> -{} - -// ============================================================================= -// Refinements -// ============================================================================= - -/** @internal */ -export const isCommand = (u: unknown): u is Command.Command => - typeof u === "object" && u != null && CommandTypeId in u - -/** @internal */ -export const isStandard = (self: Instruction): self is Standard => self._tag === "Standard" - -/** @internal */ -export const isGetUserInput = (self: Instruction): self is GetUserInput => - self._tag === "GetUserInput" + function parseValue( + value: + | Args.Args + | Options.Options + | ReadonlyArray | Options.Options | Command.Command.ConfigBase> + | Command.Command.ConfigBase + ): Command.Command.ParsedConfigNode { + if (Array.isArray(value)) { + return { + _tag: "Array", + children: ReadonlyArray.map(value, parseValue) + } + } else if (InternalArgs.isArgs(value)) { + args.push(value) + return { + _tag: "Args", + index: argsIndex++ + } + } else if (InternalOptions.isOptions(value)) { + options.push(value) + return { + _tag: "Options", + index: optionsIndex++ + } + } else { + return { + _tag: "ParsedConfig", + tree: parse(value as any) + } + } + } -/** @internal */ -export const isMap = (self: Instruction): self is Map => self._tag === "Map" + return { + args, + options, + tree: parse(config) + } +} -/** @internal */ -export const isOrElse = (self: Instruction): self is OrElse => self._tag === "OrElse" +const reconstructConfigTree = ( + tree: Command.Command.ParsedConfigTree, + args: ReadonlyArray, + options: ReadonlyArray +): Record => { + const output: Record = {} -/** @internal */ -export const isSubcommands = (self: Instruction): self is Subcommands => self._tag === "Subcommands" + for (const key in tree) { + output[key] = nodeValue(tree[key]) + } -// ============================================================================= -// Constructors -// ============================================================================= + return output -const defaultConstructorConfig = { - options: InternalOptions.none, - args: InternalArgs.none + function nodeValue(node: Command.Command.ParsedConfigNode): any { + if (node._tag === "Args") { + return args[node.index] + } else if (node._tag === "Options") { + return options[node.index] + } else if (node._tag === "Array") { + return ReadonlyArray.map(node.children, nodeValue) + } else { + return reconstructConfigTree(node.tree, args, options) + } + } } -/** @internal */ -export const make = ( - name: Name, - config: Command.Command.ConstructorConfig = defaultConstructorConfig as any -): Command.Command> => { - const { args, options } = { ...defaultConstructorConfig, ...config } - const op = Object.create(proto) - op._tag = "Standard" - op.name = name - op.description = InternalHelpDoc.empty - op.options = options - op.args = args - return op +const Prototype = { + ...Effectable.CommitPrototype, + [TypeId]: TypeId, + commit(this: Command.Command) { + return this.tag + }, + pipe() { + return pipeArguments(this, arguments) + } } -/** @internal */ -export const prompt = ( - name: Name, - prompt: Prompt.Prompt -): Command.Command> => { - const op = Object.create(proto) - op._tag = "GetUserInput" - op.name = name - op.description = InternalHelpDoc.empty - op.prompt = prompt - return op +const modifiedCommands = globalValue( + "@effect/cli/Command/modifiedCommands", + () => new WeakMap, Descriptor.Command>() +) + +const getDescriptor = (self: Command.Command) => + modifiedCommands.get(self.tag) ?? self.descriptor + +const makeProto = ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect.Effect, + tag?: Context.Tag +): Command.Command => { + console.log("makeProto", descriptor, handler, tag) + const self = Object.create(Prototype) + self.descriptor = descriptor + self.handler = handler + self.tag = tag ?? Context.Tag() + modifiedCommands.set(self.tag, self.descriptor) + return self } -// ============================================================================= -// Combinators -// ============================================================================= - -/** @internal */ -export const getHelp = (self: Command.Command): HelpDoc.HelpDoc => - getHelpInternal(self as Instruction) - /** @internal */ -export const getNames = (self: Command.Command): HashSet.HashSet => - getNamesInternal(self as Instruction) +export const fromDescriptor = dual< + ( + handler: (_: A) => Effect.Effect + ) => (command: Descriptor.Command) => Command.Command, + ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect.Effect + ) => Command.Command +>(2, (descriptor, handler) => makeProto(descriptor, handler)) /** @internal */ -export const getBashCompletions = ( - self: Command.Command, - programName: string -): Effect.Effect> => - getBashCompletionsInternal(self as Instruction, programName) +export const fromDescriptorUnit = ( + descriptor: Descriptor.Command +): Command.Command => makeProto(descriptor, (_) => Effect.unit) /** @internal */ -export const getFishCompletions = ( - self: Command.Command, - programName: string -): Effect.Effect> => - getFishCompletionsInternal(self as Instruction, programName) - -/** @internal */ -export const getZshCompletions = ( - self: Command.Command, - programName: string -): Effect.Effect> => - getZshCompletionsInternal(self as Instruction, programName) +export const fromDescriptorHelp = ( + descriptor: Descriptor.Command +): Command.Command => { + const self: Command.Command = makeProto( + descriptor, + (_) => Effect.fail(ValidationError.helpRequested(getDescriptor(self))) + ) + return self +} -/** @internal */ -export const getSubcommands = ( - self: Command.Command -): HashMap.HashMap> => getSubcommandsInternal(self as Instruction) +const makeDescriptor = ( + name: string, + config: Config +): Descriptor.Command>> => { + const { args, options, tree } = parseConfig(config) + return InternalDescriptor.map( + InternalDescriptor.make(name, InternalOptions.all(options), InternalArgs.all(args)), + ({ args, options }) => reconstructConfigTree(tree, args, options) + ) as any +} /** @internal */ -export const getUsage = (self: Command.Command): Usage.Usage => - getUsageInternal(self as Instruction) +export const make = ( + name: Name, + config: Config, + handler: (_: Types.Simplify>) => Effect.Effect +): Command.Command< + Name, + R, + E, + Types.Simplify> +> => makeProto(makeDescriptor(name, config), handler) + +/** @internal */ +export const makeUnit: { + ( + name: Name + ): Command.Command + ( + name: Name, + config: Config + ): Command.Command< + Name, + never, + never, + Types.Simplify> + > +} = (name: string, config = {}) => fromDescriptorUnit(makeDescriptor(name, config) as any) as any + +/** @internal */ +export const makeHelp: { + ( + name: Name + ): Command.Command + ( + name: Name, + config: Config + ): Command.Command< + Name, + never, + ValidationError.ValidationError, + Types.Simplify> + > +} = (name: string, config = {}) => fromDescriptorHelp(makeDescriptor(name, config) as any) as any /** @internal */ -export const map = dual< - (f: (a: A) => B) => (self: Command.Command) => Command.Command, - (self: Command.Command, f: (a: A) => B) => Command.Command ->(2, (self, f) => mapOrFail(self, (a) => Either.right(f(a)))) +export const mapDescriptor = dual< + (f: (_: Descriptor.Command) => Descriptor.Command) => ( + self: Command.Command + ) => Command.Command, + ( + self: Command.Command, + f: (_: Descriptor.Command) => Descriptor.Command + ) => Command.Command +>(2, (self, f) => makeProto(f(getDescriptor(self)), self.handler, self.tag)) /** @internal */ -export const mapOrFail = dual< +export const mapBoth = dual< ( - f: (a: A) => Either.Either - ) => (self: Command.Command) => Command.Command, - ( - self: Command.Command, - f: (a: A) => Either.Either - ) => Command.Command ->(2, (self, f) => { - const op = Object.create(proto) - op._tag = "Map" - op.command = self - op.f = f - return op -}) - -/** @internal */ -export const orElse = dual< - (that: Command.Command) => (self: Command.Command) => Command.Command, - (self: Command.Command, that: Command.Command) => Command.Command ->(2, (self, that) => { - const op = Object.create(proto) - op._tag = "OrElse" - op.left = self - op.right = that - return op -}) - -/** @internal */ -export const orElseEither = dual< - ( - that: Command.Command - ) => (self: Command.Command) => Command.Command>, - (self: Command.Command, that: Command.Command) => Command.Command> ->(2, (self, that) => orElse(map(self, Either.left), map(that, Either.right))) - -/** @internal */ -export const parse = dual< - ( - args: ReadonlyArray, - config: CliConfig.CliConfig - ) => (self: Command.Command) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - CommandDirective.CommandDirective - >, - ( - self: Command.Command, - args: ReadonlyArray, - config: CliConfig.CliConfig - ) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - CommandDirective.CommandDirective - > ->(3, (self, args, config) => parseInternal(self as Instruction, args, config)) + f: (_: Descriptor.Command) => Descriptor.Command, + g: (_: B) => A + ) => ( + self: Command.Command + ) => Command.Command, + ( + self: Command.Command, + f: (_: Descriptor.Command) => Descriptor.Command, + g: (_: B) => A + ) => Command.Command +>(3, (self, f, g) => makeProto(f(getDescriptor(self)), (_) => self.handler(g(_)), self.tag)) + +/** @internal */ +export const prompt = ( + name: Name, + prompt: Prompt.Prompt, + handler: (_: A) => Effect.Effect +) => + makeProto( + InternalDescriptor.map( + InternalDescriptor.prompt(name, prompt), + (_) => _.value + ), + handler + ) /** @internal */ export const withDescription = dual< - (help: string | HelpDoc.HelpDoc) => (self: Command.Command) => Command.Command, - (self: Command.Command, help: string | HelpDoc.HelpDoc) => Command.Command ->(2, (self, help) => withDescriptionInternal(self as Instruction, help)) + ( + help: string | HelpDoc + ) => ( + self: Command.Command + ) => Command.Command, + ( + self: Command.Command, + help: string | HelpDoc + ) => Command.Command +>(2, (self, help) => mapDescriptor(self, InternalDescriptor.withDescription(help))) /** @internal */ export const withSubcommands = dual< - >>( - subcommands: [...Subcommands] - ) => ( - self: Command.Command - ) => Command.Command< - Command.Command.ComputeParsedType< - A & Readonly<{ subcommand: Option.Option> }> + >>( + subcommands: Subcommand + ) => (self: Command.Command) => Command.Command< + Name, + | R + | Exclude< + Effect.Effect.Context>, + Descriptor.Command + >, + E | Effect.Effect.Error>, + Descriptor.Command.ComputeParsedType< + & A + & Readonly< + { + subcommand: Option.Option< + Descriptor.Command.GetParsedType + > + } + > > >, - >>( - self: Command.Command, - subcommands: [...Subcommands] + < + Name extends string, + R, + E, + A, + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + >( + self: Command.Command, + subcommands: Subcommand ) => Command.Command< - Command.Command.ComputeParsedType< - A & Readonly<{ subcommand: Option.Option> }> + Name, + | R + | Exclude< + Effect.Effect.Context>, + Descriptor.Command + >, + E | Effect.Effect.Error>, + Descriptor.Command.ComputeParsedType< + & A + & Readonly< + { + subcommand: Option.Option< + Descriptor.Command.GetParsedType + > + } + > > > >(2, (self, subcommands) => { - const op = Object.create(proto) - op._tag = "Subcommands" - op.parent = self - if (ReadonlyArray.isNonEmptyReadonlyArray(subcommands)) { - const head = ReadonlyArray.headNonEmpty>(subcommands) - const tail = ReadonlyArray.tailNonEmpty>(subcommands) - op.child = ReadonlyArray.isNonEmptyReadonlyArray(tail) - ? ReadonlyArray.reduce(tail, head, orElse) - : head - return op - } - throw new Error("[BUG]: Command.subcommands - received empty list of subcommands") -}) - -/** @internal */ -export const wizard = dual< - (config: CliConfig.CliConfig) => (self: Command.Command) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - ReadonlyArray - >, - (self: Command.Command, config: CliConfig.CliConfig) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - ReadonlyArray - > ->(2, (self, config) => wizardInternal(self as Instruction, config)) - -// ============================================================================= -// Internals -// ============================================================================= - -const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { - switch (self._tag) { - case "Standard": { - const header = InternalHelpDoc.isEmpty(self.description) - ? InternalHelpDoc.empty - : InternalHelpDoc.sequence(InternalHelpDoc.h1("DESCRIPTION"), self.description) - const argsHelp = InternalArgs.getHelp(self.args) - const argsSection = InternalHelpDoc.isEmpty(argsHelp) - ? InternalHelpDoc.empty - : InternalHelpDoc.sequence(InternalHelpDoc.h1("ARGUMENTS"), argsHelp) - const optionsHelp = InternalOptions.getHelp(self.options) - const optionsSection = InternalHelpDoc.isEmpty(optionsHelp) - ? InternalHelpDoc.empty - : InternalHelpDoc.sequence(InternalHelpDoc.h1("OPTIONS"), optionsHelp) - return InternalHelpDoc.sequence(header, InternalHelpDoc.sequence(argsSection, optionsSection)) - } - case "GetUserInput": { - return InternalHelpDoc.isEmpty(self.description) - ? InternalHelpDoc.empty - : InternalHelpDoc.sequence(InternalHelpDoc.h1("DESCRIPTION"), self.description) - } - case "Map": { - return getHelpInternal(self.command as Instruction) - } - case "OrElse": { - return InternalHelpDoc.sequence( - getHelpInternal(self.left as Instruction), - getHelpInternal(self.right as Instruction) - ) - } - case "Subcommands": { - const getUsage = ( - command: Instruction, - preceding: ReadonlyArray - ): ReadonlyArray<[Span.Span, Span.Span]> => { - switch (command._tag) { - case "Standard": - case "GetUserInput": { - const usage = InternalHelpDoc.getSpan(InternalUsage.getHelp(getUsageInternal(command))) - const usages = ReadonlyArray.append(preceding, usage) - const finalUsage = ReadonlyArray.reduce( - usages, - InternalSpan.empty, - (acc, next) => - InternalSpan.isText(acc) && acc.value === "" - ? next - : InternalSpan.isText(next) && next.value === "" - ? acc - : InternalSpan.spans([acc, InternalSpan.space, next]) - ) - const description = InternalHelpDoc.getSpan(command.description) - return ReadonlyArray.of([finalUsage, description]) - } - case "Map": { - return getUsage(command.command as Instruction, preceding) - } - case "OrElse": { - return ReadonlyArray.appendAll( - getUsage(command.left as Instruction, preceding), - getUsage(command.right as Instruction, preceding) - ) - } - case "Subcommands": { - const parentUsage = getUsage(command.parent as Instruction, preceding) - return Option.match(ReadonlyArray.head(parentUsage), { - onNone: () => getUsage(command.child as Instruction, preceding), - onSome: ([usage]) => { - const childUsage = getUsage( - command.child as Instruction, - ReadonlyArray.append(preceding, usage) - ) - return ReadonlyArray.appendAll(parentUsage, childUsage) - } - }) - } - } - } - const printSubcommands = ( - subcommands: ReadonlyArray<[Span.Span, Span.Span]> - ): HelpDoc.HelpDoc => { - const maxUsageLength = ReadonlyArray.reduceRight( - subcommands, - 0, - (max, [usage]) => Math.max(InternalSpan.size(usage), max) - ) - const documents = ReadonlyArray.map(subcommands, ([usage, desc]) => - InternalHelpDoc.p( - InternalSpan.spans([ - usage, - InternalSpan.text(" ".repeat(maxUsageLength - InternalSpan.size(usage) + 2)), - desc - ]) - )) - if (ReadonlyArray.isNonEmptyReadonlyArray(documents)) { - return InternalHelpDoc.enumeration(documents) - } - throw new Error("[BUG]: Subcommands.usage - received empty list of subcommands to print") - } - return InternalHelpDoc.sequence( - getHelpInternal(self.parent as Instruction), - InternalHelpDoc.sequence( - InternalHelpDoc.h1("COMMANDS"), - printSubcommands(getUsage(self.child as Instruction, ReadonlyArray.empty())) - ) - ) - } - } -} - -const getNamesInternal = (self: Instruction): HashSet.HashSet => { - switch (self._tag) { - case "Standard": - case "GetUserInput": { - return HashSet.make(self.name) - } - case "Map": { - return getNamesInternal(self.command as Instruction) - } - case "OrElse": { - return HashSet.union( - getNamesInternal(self.right as Instruction), - getNamesInternal(self.left as Instruction) - ) - } - case "Subcommands": { - return getNamesInternal(self.parent as Instruction) - } - } -} - -const getSubcommandsInternal = ( - self: Instruction -): HashMap.HashMap => { - switch (self._tag) { - case "Standard": - case "GetUserInput": { - return HashMap.make([self.name, self]) - } - case "Map": { - return getSubcommandsInternal(self.command as Instruction) - } - case "OrElse": { - return HashMap.union( - getSubcommandsInternal(self.left as Instruction), - getSubcommandsInternal(self.right as Instruction) - ) - } - case "Subcommands": { - return getSubcommandsInternal(self.parent as Instruction) - } - } -} - -const getUsageInternal = (self: Instruction): Usage.Usage => { - switch (self._tag) { - case "Standard": { - return InternalUsage.concat( - InternalUsage.named(ReadonlyArray.of(self.name), Option.none()), - InternalUsage.concat( - InternalOptions.getUsage(self.options), - InternalArgs.getUsage(self.args) - ) - ) - } - case "GetUserInput": { - return InternalUsage.named(ReadonlyArray.of(self.name), Option.none()) - } - case "Map": { - return getUsageInternal(self.command as Instruction) - } - case "OrElse": { - return InternalUsage.mixed - } - case "Subcommands": { - return InternalUsage.concat( - getUsageInternal(self.parent as Instruction), - getUsageInternal(self.child as Instruction) - ) - } - } -} - -const parseInternal = ( - self: Instruction, - args: ReadonlyArray, - config: CliConfig.CliConfig -): Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - CommandDirective.CommandDirective -> => { - switch (self._tag) { - case "Standard": { - const parseCommandLine = ( - args: ReadonlyArray - ): Effect.Effect> => { - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - const head = ReadonlyArray.headNonEmpty(args) - const tail = ReadonlyArray.tailNonEmpty(args) - const normalizedArgv0 = InternalCliConfig.normalizeCase(config, head) - const normalizedCommandName = InternalCliConfig.normalizeCase(config, self.name) - return Effect.succeed(tail).pipe( - Effect.when(() => normalizedArgv0 === normalizedCommandName), - Effect.flatten, - Effect.catchTag("NoSuchElementException", () => { - const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) - return Effect.fail(InternalValidationError.commandMismatch(error)) - }) - ) - } - const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) - return Effect.fail(InternalValidationError.commandMismatch(error)) - } - const parseBuiltInArgs = ( - args: ReadonlyArray - ): Effect.Effect< - FileSystem.FileSystem, - ValidationError.ValidationError, - CommandDirective.CommandDirective - > => { - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - const argv0 = ReadonlyArray.headNonEmpty(args) - const normalizedArgv0 = InternalCliConfig.normalizeCase(config, argv0) - const normalizedCommandName = InternalCliConfig.normalizeCase(config, self.name) - if (normalizedArgv0 === normalizedCommandName) { - const help = getHelpInternal(self) - const usage = getUsageInternal(self) - const options = InternalBuiltInOptions.builtInOptions(self, usage, help) - return InternalOptions.validate(options, ReadonlyArray.drop(args, 1), config).pipe( - Effect.flatMap((tuple) => tuple[2]), - Effect.catchTag("NoSuchElementException", () => { - const error = InternalHelpDoc.p("No built-in option was matched") - return Effect.fail(InternalValidationError.noBuiltInMatch(error)) - }), - Effect.map(InternalCommandDirective.builtIn) - ) - } - } - const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) - return Effect.fail(InternalValidationError.commandMismatch(error)) - } - const parseUserDefinedArgs = ( - args: ReadonlyArray - ): Effect.Effect< - FileSystem.FileSystem, - ValidationError.ValidationError, - CommandDirective.CommandDirective - > => - parseCommandLine(args).pipe(Effect.flatMap((commandOptionsAndArgs) => { - const [optionsAndArgs, forcedCommandArgs] = splitForcedArgs(commandOptionsAndArgs) - return InternalOptions.validate(self.options, optionsAndArgs, config).pipe( - Effect.flatMap(([error, commandArgs, optionsType]) => - InternalArgs.validate( - self.args, - ReadonlyArray.appendAll(commandArgs, forcedCommandArgs), - config - ).pipe( - Effect.catchAll((e) => - Option.match(error, { - onNone: () => Effect.fail(e), - onSome: (err) => Effect.fail(err) - }) - ), - Effect.map(([argsLeftover, argsType]) => - InternalCommandDirective.userDefined(argsLeftover, { - name: self.name, - options: optionsType, - args: argsType - }) - ) - ) - ) - ) - })) - const exhaustiveSearch = ( - args: ReadonlyArray - ): Effect.Effect< - FileSystem.FileSystem, - ValidationError.ValidationError, - CommandDirective.CommandDirective - > => { - if (ReadonlyArray.contains(args, "--help") || ReadonlyArray.contains(args, "-h")) { - return parseBuiltInArgs(ReadonlyArray.make(self.name, "--help")) - } - if (ReadonlyArray.contains(args, "--wizard")) { - return parseBuiltInArgs(ReadonlyArray.make(self.name, "--wizard")) - } - if (ReadonlyArray.contains(args, "--version")) { - return parseBuiltInArgs(ReadonlyArray.make(self.name, "--version")) - } - const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) - return Effect.fail(InternalValidationError.commandMismatch(error)) - } - return parseBuiltInArgs(args).pipe( - Effect.orElse(() => parseUserDefinedArgs(args)), - Effect.catchSome((e) => { - if (InternalValidationError.isValidationError(e)) { - if (config.finalCheckBuiltIn) { - return Option.some( - exhaustiveSearch(args).pipe( - Effect.catchSome((_) => - InternalValidationError.isValidationError(_) - ? Option.some(Effect.fail(e)) - : Option.none() - ) - ) - ) - } - return Option.some(Effect.fail(e)) - } - return Option.none() - }) - ) - } - case "GetUserInput": { - return InternalPrompt.run(self.prompt).pipe( - Effect.map((value) => - InternalCommandDirective.userDefined(ReadonlyArray.drop(args, 1), { - name: self.name, - value - }) - ) - ) - } - case "Map": { - return parseInternal(self.command as Instruction, args, config).pipe( - Effect.flatMap((directive) => { - if (InternalCommandDirective.isUserDefined(directive)) { - const either = self.f(directive.value) - return Either.isLeft(either) - ? Effect.fail(either.left) - : Effect.succeed(InternalCommandDirective.userDefined( - directive.leftover, - either.right - )) - } - return Effect.succeed(directive) - }) - ) - } - case "OrElse": { - return parseInternal(self.left as Instruction, args, config).pipe( - Effect.catchSome((e) => { - return InternalValidationError.isCommandMismatch(e) - ? Option.some(parseInternal(self.right as Instruction, args, config)) - : Option.none() - }) - ) - } - case "Subcommands": { - const names = Array.from(getNamesInternal(self)) - const subcommands = getSubcommandsInternal(self.child as Instruction) - const [parentArgs, childArgs] = ReadonlyArray.span( - args, - (name) => !HashMap.has(subcommands, name) - ) - const helpDirectiveForParent = Effect.sync(() => { - return InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( - getUsageInternal(self), - getHelpInternal(self) - )) - }) - 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 = 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)) { - // 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) - } - return Effect.succeed(directive) - } - case "UserDefined": { - const args = ReadonlyArray.appendAll(directive.leftover, childArgs) - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - return parseInternal(self.child as Instruction, args, config).pipe(Effect.mapBoth({ - onFailure: (err) => { - if (InternalValidationError.isCommandMismatch(err)) { - const parentName = Option.getOrElse(ReadonlyArray.head(names), () => "") - const subcommandNames = pipe( - ReadonlyArray.fromIterable(HashMap.keys(subcommands)), - ReadonlyArray.map((name) => `'${name}'`) - ) - const oneOf = subcommandNames.length === 1 ? "" : " one of" - const error = InternalHelpDoc.p( - `Invalid subcommand for ${parentName} - use${oneOf} ${ - ReadonlyArray.join(subcommandNames, ", ") - }` - ) - return InternalValidationError.commandMismatch(error) - } - return err - }, - onSuccess: InternalCommandDirective.map((subcommand) => ({ - ...directive.value as any, - subcommand: Option.some(subcommand) - })) - })) - } - return Effect.succeed(InternalCommandDirective.userDefined(directive.leftover, { - ...directive.value as any, - subcommand: Option.none() - })) - } - } - }), - Effect.catchSome(() => - ReadonlyArray.isEmptyReadonlyArray(args) - ? Option.some(helpDirectiveForParent) : - Option.none() - ) - ) - } - } -} - -const splitForcedArgs = ( - args: ReadonlyArray -): [ReadonlyArray, ReadonlyArray] => { - const [remainingArgs, forcedArgs] = ReadonlyArray.span(args, (str) => str !== "--") - return [remainingArgs, ReadonlyArray.drop(forcedArgs, 1)] -} - -const withDescriptionInternal = ( - self: Instruction, - description: string | HelpDoc.HelpDoc -): Command.Command => { - switch (self._tag) { - case "Standard": { - const helpDoc = typeof description === "string" ? HelpDoc.p(description) : description - const op = Object.create(proto) - op._tag = "Standard" - op.name = self.name - op.description = helpDoc - op.options = self.options - op.args = self.args - return op - } - case "GetUserInput": { - const helpDoc = typeof description === "string" ? HelpDoc.p(description) : description - const op = Object.create(proto) - op._tag = "GetUserInput" - op.name = self.name - op.description = helpDoc - op.prompt = self.prompt - return op - } - case "Map": { - return map(withDescriptionInternal(self.command as Instruction, description), self.f) - } - case "OrElse": { - // TODO: if both the left and right commands also have help defined, that - // help will be overwritten by this method which may be undesirable - return orElse( - withDescriptionInternal(self.left as Instruction, description), - withDescriptionInternal(self.right as Instruction, description) - ) - } - case "Subcommands": { - const op = Object.create(proto) - op._tag = "Subcommands" - op.parent = withDescriptionInternal(self.parent as Instruction, description) - op.child = self.child - return op - } - } -} - -const wizardInternal = ( - self: Instruction, - config: CliConfig.CliConfig -): Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, - ReadonlyArray -> => { - 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": { - console.log(self.parent) - 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 { _tag: "SingleCommandWizard", command: self } - } - case "Map": { - return getWizardCommandSequence(self.command as Instruction) - } - case "OrElse": { - 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 } + const command = InternalDescriptor.withSubcommands( + self.descriptor, + ReadonlyArray.map(subcommands, (_) => [_.tag, _.descriptor]) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + new Map, (_: any) => Effect.Effect>(), + (handlers, subcommand) => { + handlers.set(subcommand.tag, subcommand.handler) + return handlers } - case "Subcommands": { - const names = pipe( - ReadonlyArray.fromIterable(getNamesInternal(self.parent as Instruction)), - ReadonlyArray.join(" | ") + ) + function handler( + args: { + readonly name: string + readonly subcommand: Option.Option, value: unknown]> + } + ) { + if (args.subcommand._tag === "Some") { + const [tag, value] = args.subcommand.value + return Effect.provideService( + handlers.get(tag)!(value), + self.tag, + args as any ) - const parent = getWizardCommandSequence(self.parent as Instruction) - const child = getWizardCommandSequence(self.child as Instruction) - return { _tag: "SubcommandWizard", names, parent, child } - } - } -} - -// ============================================================================= -// Completion Internals -// ============================================================================= - -const getShortDescription = (self: Instruction): string => { - switch (self._tag) { - case "Standard": { - return InternalSpan.getText(InternalHelpDoc.getSpan(self.description)) - } - case "GetUserInput": { - return InternalSpan.getText(InternalHelpDoc.getSpan(self.description)) - } - case "Map": { - return getShortDescription(self.command as Instruction) - } - case "OrElse": - case "Subcommands": { - return "" } + return self.handler(args as any) } -} - -interface CommandInfo { - readonly command: Standard | GetUserInput - readonly parentCommands: ReadonlyArray - readonly subcommands: ReadonlyArray<[string, Standard | GetUserInput]> - readonly level: number -} - -/** - * Allows for linear traversal of a `Command` data structure, accumulating state - * based on information acquired from the command. - */ -const traverseCommand = ( - self: Instruction, - initialState: S, - f: (state: S, info: CommandInfo) => Effect.Effect -): Effect.Effect => - SynchronizedRef.make(initialState).pipe(Effect.flatMap((ref) => { - const loop = ( - self: Instruction, - parentCommands: ReadonlyArray, - subcommands: ReadonlyArray<[string, Standard | GetUserInput]>, - level: number - ): Effect.Effect => { - switch (self._tag) { - case "Standard": { - const info: CommandInfo = { - command: self, - parentCommands, - subcommands, - level - } - return SynchronizedRef.updateEffect(ref, (state) => f(state, info)) - } - case "GetUserInput": { - const info: CommandInfo = { - command: self, - parentCommands, - subcommands, - level - } - return SynchronizedRef.updateEffect(ref, (state) => f(state, info)) - } - case "Map": { - return loop(self.command as Instruction, parentCommands, subcommands, level) - } - case "OrElse": { - const left = loop(self.left as Instruction, parentCommands, subcommands, level) - const right = loop(self.right as Instruction, parentCommands, subcommands, level) - return Effect.zipRight(left, right) - } - case "Subcommands": { - const parentNames = Array.from(getNamesInternal(self.parent as Instruction)) - const nextSubcommands = Array.from(getSubcommandsInternal(self.child as Instruction)) - const nextParentCommands = ReadonlyArray.appendAll(parentCommands, parentNames) - // Traverse the parent command using old parent names and next subcommands - return loop(self.parent as Instruction, parentCommands, nextSubcommands, level).pipe( - Effect.zipRight( - // Traverse the child command using next parent names and old subcommands - loop(self.child as Instruction, nextParentCommands, subcommands, level + 1) - ) - ) - } - } - } - return Effect.suspend(() => loop(self, ReadonlyArray.empty(), ReadonlyArray.empty(), 0)).pipe( - Effect.zipRight(SynchronizedRef.get(ref)) - ) - })) - -const indentAll = dual< - (indent: number) => (self: ReadonlyArray) => ReadonlyArray, - (self: ReadonlyArray, indent: number) => ReadonlyArray ->(2, (self: ReadonlyArray, indent: number): ReadonlyArray => { - const indentation = new Array(indent + 1).join(" ") - return ReadonlyArray.map(self, (line) => `${indentation}${line}`) + return makeProto(command as any, handler, self.tag) as any }) -const getBashCompletionsInternal = ( - self: Instruction, - rootCommand: string -): Effect.Effect> => - traverseCommand( - self, - ReadonlyArray.empty<[ReadonlyArray, ReadonlyArray]>(), - (state, info) => { - const options = isStandard(info.command) - ? Options.all([info.command.options, InternalBuiltInOptions.builtIns]) - : InternalBuiltInOptions.builtIns - const optionNames = InternalOptions.getNames(options as InternalOptions.Instruction) - const optionCases = isStandard(info.command) - ? InternalOptions.getBashCompletions(info.command.options as InternalOptions.Instruction) - : ReadonlyArray.empty() - const subcommandNames = pipe( - info.subcommands, - ReadonlyArray.map(([name]) => name), - ReadonlyArray.sort(Order.string) - ) - const wordList = ReadonlyArray.appendAll(optionNames, subcommandNames) - const preformatted = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) - ? ReadonlyArray.of(info.command.name) - : pipe( - info.parentCommands, - ReadonlyArray.append(info.command.name), - ReadonlyArray.map((command) => command.replace("-", "__")) - ) - const caseName = ReadonlyArray.join(preformatted, ",") - const funcName = ReadonlyArray.join(preformatted, "__") - const funcLines = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) - ? ReadonlyArray.empty() - : [ - `${caseName})`, - ` cmd="${funcName}"`, - " ;;" - ] - const cmdLines = [ - `${funcName})`, - ` opts="${ReadonlyArray.join(wordList, " ")}"`, - ` if [[ \${cur} == -* || \${COMP_CWORD} -eq ${info.level + 1} ]] ; then`, - " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )", - " return 0", - " fi", - " case \"${prev}\" in", - ...indentAll(optionCases, 8), - " *)", - " COMPREPLY=()", - " ;;", - " esac", - " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )", - " return 0", - " ;;" - ] - const lines = ReadonlyArray.append( - state, - [funcLines, cmdLines] as [ReadonlyArray, ReadonlyArray] - ) - return Effect.succeed(lines) - } - ).pipe(Effect.map((lines) => { - const scriptName = `_${rootCommand}_bash_completions` - const funcCases = ReadonlyArray.flatMap(lines, ([funcLines]) => funcLines) - const cmdCases = ReadonlyArray.flatMap(lines, ([, cmdLines]) => cmdLines) - return [ - `function ${scriptName}() {`, - " local i cur prev opts cmd", - " COMPREPLY=()", - " cur=\"${COMP_WORDS[COMP_CWORD]}\"", - " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"", - " cmd=\"\"", - " opts=\"\"", - " for i in \"${COMP_WORDS[@]}\"; do", - " case \"${cmd},${i}\" in", - " \",$1\")", - ` cmd="${rootCommand}"`, - " ;;", - ...indentAll(funcCases, 12), - " *)", - " ;;", - " esac", - " done", - " case \"${cmd}\" in", - ...indentAll(cmdCases, 8), - " esac", - "}", - `complete -F ${scriptName} -o nosort -o bashdefault -o default ${rootCommand}` - ] - })) - -const getFishCompletionsInternal = ( - self: Instruction, - rootCommand: string -): Effect.Effect> => - traverseCommand(self, ReadonlyArray.empty(), (state, info) => { - const baseTemplate = ReadonlyArray.make("complete", "-c", rootCommand) - const options = isStandard(info.command) - ? InternalOptions.all([InternalBuiltInOptions.builtIns, info.command.options]) - : InternalBuiltInOptions.builtIns - const optionsCompletions = InternalOptions.getFishCompletions( - options as InternalOptions.Instruction - ) - const argsCompletions = isStandard(info.command) - ? InternalArgs.getFishCompletions(info.command.args as InternalArgs.Instruction) - : ReadonlyArray.empty() - const rootCompletions = (conditionals: ReadonlyArray) => - pipe( - ReadonlyArray.map(optionsCompletions, (option) => - pipe( - baseTemplate, - ReadonlyArray.appendAll(conditionals), - ReadonlyArray.append(option), - ReadonlyArray.join(" ") - )), - ReadonlyArray.appendAll( - ReadonlyArray.map(argsCompletions, (option) => - pipe( - baseTemplate, - ReadonlyArray.appendAll(conditionals), - ReadonlyArray.append(option), - ReadonlyArray.join(" ") - )) - ) - ) - const subcommandCompletions = (conditionals: ReadonlyArray) => - ReadonlyArray.map(info.subcommands, ([name, subcommand]) => { - const description = getShortDescription(subcommand) - return pipe( - baseTemplate, - ReadonlyArray.appendAll(conditionals), - ReadonlyArray.appendAll(ReadonlyArray.make("-f", "-a", `"${name}"`)), - ReadonlyArray.appendAll( - description.length === 0 - ? ReadonlyArray.empty() - : ReadonlyArray.make("-d", `'${description}'`) - ), - ReadonlyArray.join(" ") - ) - }) - // If parent commands are empty, then the info is for the root command - if (ReadonlyArray.isEmptyReadonlyArray(info.parentCommands)) { - const conditionals = ReadonlyArray.make("-n", "\"__fish_use_subcommand\"") - return Effect.succeed(pipe( - state, - ReadonlyArray.appendAll(rootCompletions(conditionals)), - ReadonlyArray.appendAll(subcommandCompletions(conditionals)) - )) - } - // Otherwise the info is for a subcommand - const parentConditionals = pipe( - info.parentCommands, - // Drop the root command name from the subcommand conditionals - ReadonlyArray.drop(1), - ReadonlyArray.append(info.command.name), - ReadonlyArray.map((command) => `__fish_seen_subcommand_from ${command}`) - ) - const subcommandConditionals = ReadonlyArray.map( - info.subcommands, - ([name]) => `not __fish_seen_subcommand_from ${name}` - ) - const baseConditionals = pipe( - ReadonlyArray.appendAll(parentConditionals, subcommandConditionals), - ReadonlyArray.join("; and ") - ) - const conditionals = ReadonlyArray.make("-n", `"${baseConditionals}"`) - return Effect.succeed(pipe( - state, - ReadonlyArray.appendAll(rootCompletions(conditionals)), - ReadonlyArray.appendAll(subcommandCompletions(conditionals)) - )) - }) - -const getZshCompletionsInternal = ( - self: Instruction, - rootCommand: string -): Effect.Effect> => - traverseCommand(self, ReadonlyArray.empty(), (state, info) => { - const preformatted = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) - ? ReadonlyArray.of(info.command.name) - : pipe( - info.parentCommands, - ReadonlyArray.append(info.command.name), - ReadonlyArray.map((command) => command.replace("-", "__")) - ) - const underscoreName = ReadonlyArray.join(preformatted, "__") - const spaceName = ReadonlyArray.join(preformatted, " ") - const subcommands = pipe( - info.subcommands, - ReadonlyArray.map(([name, subcommand]) => { - const desc = getShortDescription(subcommand) - return `'${name}:${desc}' \\` - }) - ) - const commands = ReadonlyArray.isEmptyReadonlyArray(subcommands) - ? `commands=()` - : `commands=(\n${ReadonlyArray.join(indentAll(subcommands, 8), "\n")}\n )` - const handlerLines = [ - `(( $+functions[_${underscoreName}_commands] )) ||`, - `_${underscoreName}_commands() {`, - ` local commands; ${commands}`, - ` _describe -t commands '${spaceName} commands' commands "$@"`, - "}" - ] - return Effect.succeed(ReadonlyArray.appendAll(state, handlerLines)) - }).pipe(Effect.map((handlers) => { - const cases = getZshSubcommandCases(self, ReadonlyArray.empty(), ReadonlyArray.empty()) - const scriptName = `_${rootCommand}_zsh_completions` - return [ - `#compdef ${rootCommand}`, - "", - "autoload -U is-at-least", - "", - `function ${scriptName}() {`, - " typeset -A opt_args", - " typeset -a _arguments_options", - " local ret=1", - "", - " if is-at-least 5.2; then", - " _arguments_options=(-s -S -C)", - " else", - " _arguments_options=(-s -C)", - " fi", - "", - " local context curcontext=\"$curcontext\" state line", - ...indentAll(cases, 4), - "}", - "", - ...handlers, - "", - `if [ "$funcstack[1]" = "${scriptName}" ]; then`, - ` ${scriptName} "$@"`, - "else", - ` compdef ${scriptName} ${rootCommand}`, - "fi" - ] - })) - -const getZshSubcommandCases = ( - self: Instruction, - parentCommands: ReadonlyArray, - subcommands: ReadonlyArray<[string, Standard | GetUserInput]> -): ReadonlyArray => { - switch (self._tag) { - case "Standard": - case "GetUserInput": { - const options = isStandard(self) - ? InternalOptions.all([InternalBuiltInOptions.builtIns, self.options]) - : InternalBuiltInOptions.builtIns - const optionCompletions = pipe( - InternalOptions.getZshCompletions(options as InternalOptions.Instruction), - ReadonlyArray.map((completion) => `'${completion}' \\`) - ) - if (ReadonlyArray.isEmptyReadonlyArray(parentCommands)) { - return [ - "_arguments \"${_arguments_options[@]}\" \\", - ...indentAll(optionCompletions, 4), - ` ":: :_${self.name}_commands" \\`, - ` "*::: :->${self.name}" \\`, - " && ret=0" - ] - } - if (ReadonlyArray.isEmptyReadonlyArray(subcommands)) { - return [ - `(${self.name})`, - "_arguments \"${_arguments_options[@]}\" \\", - ...indentAll(optionCompletions, 4), - " && ret=0", - ";;" - ] - } - return [ - `(${self.name})`, - "_arguments \"${_arguments_options[@]}\" \\", - ...indentAll(optionCompletions, 4), - ` ":: :_${ReadonlyArray.append(parentCommands, self.name).join("__")}_commands" \\`, - ` "*::: :->${self.name}" \\`, - " && ret=0" - ] - } - case "Map": { - return getZshSubcommandCases(self.command as Instruction, parentCommands, subcommands) - } - case "OrElse": { - const left = getZshSubcommandCases(self.left as Instruction, parentCommands, subcommands) - const right = getZshSubcommandCases(self.right as Instruction, parentCommands, subcommands) - return ReadonlyArray.appendAll(left, right) - } - case "Subcommands": { - const nextSubcommands = Array.from(getSubcommandsInternal(self.child as Instruction)) - const parentNames = Array.from(getNamesInternal(self.parent as Instruction)) - const parentLines = getZshSubcommandCases( - self.parent as Instruction, - parentCommands, - ReadonlyArray.appendAll(subcommands, nextSubcommands) - ) - const childCases = getZshSubcommandCases( - self.child as Instruction, - ReadonlyArray.appendAll(parentCommands, parentNames), - subcommands - ) - const hyphenName = pipe( - ReadonlyArray.appendAll(parentCommands, parentNames), - ReadonlyArray.join("-") - ) - const childLines = pipe( - parentNames, - ReadonlyArray.flatMap((parentName) => [ - "case $state in", - ` (${parentName})`, - ` words=($line[1] "\${words[@]}")`, - " (( CURRENT += 1 ))", - ` curcontext="\${curcontext%:*:*}:${hyphenName}-command-$line[1]:"`, - ` case $line[1] in`, - ...indentAll(childCases, 8), - " esac", - " ;;", - "esac" - ]), - ReadonlyArray.appendAll( - ReadonlyArray.isEmptyReadonlyArray(parentCommands) - ? ReadonlyArray.empty() - : ReadonlyArray.of(";;") - ) - ) - return ReadonlyArray.appendAll(parentLines, childLines) - } - } -} - -// 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 -} +export const run = dual< + (config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + }) => ( + self: Command.Command + ) => ( + args: ReadonlyArray + ) => Effect.Effect, + (self: Command.Command, config: { + readonly name: string + readonly version: string + readonly summary?: Span | undefined + readonly footer?: HelpDoc | undefined + }) => ( + args: ReadonlyArray + ) => Effect.Effect +>(2, (self, config) => { + const app = InternalCliApp.make({ + ...config, + command: self.descriptor + }) + return (args) => InternalCliApp.run(app, args, self.handler) +}) diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts new file mode 100644 index 0000000..37c035d --- /dev/null +++ b/src/internal/commandDescriptor.ts @@ -0,0 +1,1434 @@ +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Terminal from "@effect/platform/Terminal" +import * as Console from "effect/Console" +import * as Effect from "effect/Effect" +import * as Either from "effect/Either" +import { dual, pipe } from "effect/Function" +import * as HashMap from "effect/HashMap" +import * as HashSet from "effect/HashSet" +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 SynchronizedRef from "effect/SynchronizedRef" +import type * as Args from "../Args.js" +import type * as CliConfig from "../CliConfig.js" +import type * as Descriptor from "../CommandDescriptor.js" +import type * as Directive from "../CommandDirective.js" +import * as HelpDoc from "../HelpDoc.js" +import type * as Span from "../HelpDoc/Span.js" +import * as Options from "../Options.js" +import type * as Prompt from "../Prompt.js" +import type * as Usage from "../Usage.js" +import type * as ValidationError from "../ValidationError.js" +import * as InternalArgs from "./args.js" +import * as InternalBuiltInOptions from "./builtInOptions.js" +import * as InternalCliConfig from "./cliConfig.js" +import * as InternalCommandDirective from "./commandDirective.js" +import * as InternalHelpDoc from "./helpDoc.js" +import * as InternalSpan from "./helpDoc/span.js" +import * as InternalOptions from "./options.js" +import * as InternalPrompt from "./prompt.js" +import * as InternalSelectPrompt from "./prompt/select.js" +import * as InternalUsage from "./usage.js" +import * as InternalValidationError from "./validationError.js" + +const CommandDescriptorSymbolKey = "@effect/cli/CommandDescriptor" + +/** @internal */ +export const TypeId: Descriptor.TypeId = Symbol.for( + CommandDescriptorSymbolKey +) as Descriptor.TypeId + +/** @internal */ +export type Op = Descriptor.Command & Body & { + readonly _tag: Tag +} + +const proto = { + [TypeId]: { + _A: (_: never) => _ + }, + pipe() { + return pipeArguments(this, arguments) + } +} + +/** @internal */ +export type Instruction = + | Standard + | GetUserInput + | Map + | OrElse + | Subcommands + +/** @internal */ +export interface Standard extends + Op<"Standard", { + readonly name: string + readonly description: HelpDoc.HelpDoc + readonly options: Options.Options + readonly args: Args.Args + }> +{} + +/** @internal */ +export interface GetUserInput extends + Op<"GetUserInput", { + readonly name: string + readonly description: HelpDoc.HelpDoc + readonly prompt: Prompt.Prompt + }> +{} + +/** @internal */ +export interface Map extends + Op<"Map", { + readonly command: Descriptor.Command + readonly f: (value: unknown) => Either.Either + }> +{} + +/** @internal */ +export interface OrElse extends + Op<"OrElse", { + readonly left: Descriptor.Command + readonly right: Descriptor.Command + }> +{} + +/** @internal */ +export interface Subcommands extends + Op<"Subcommands", { + readonly parent: Descriptor.Command + readonly child: Descriptor.Command + }> +{} + +// ============================================================================= +// Refinements +// ============================================================================= + +/** @internal */ +export const isCommand = (u: unknown): u is Descriptor.Command => + typeof u === "object" && u != null && TypeId in u + +/** @internal */ +export const isStandard = (self: Instruction): self is Standard => self._tag === "Standard" + +/** @internal */ +export const isGetUserInput = (self: Instruction): self is GetUserInput => + self._tag === "GetUserInput" + +/** @internal */ +export const isMap = (self: Instruction): self is Map => self._tag === "Map" + +/** @internal */ +export const isOrElse = (self: Instruction): self is OrElse => self._tag === "OrElse" + +/** @internal */ +export const isSubcommands = (self: Instruction): self is Subcommands => self._tag === "Subcommands" + +// ============================================================================= +// Constructors +// ============================================================================= + +/** @internal */ +export const make = ( + name: Name, + options: Options.Options = InternalOptions.none as any, + args: Args.Args = InternalArgs.none as any +): Descriptor.Command< + Descriptor.Command.ParsedStandardCommand +> => { + const op = Object.create(proto) + op._tag = "Standard" + op.name = name + op.description = InternalHelpDoc.empty + op.options = options + op.args = args + return op +} + +/** @internal */ +export const prompt = ( + name: Name, + prompt: Prompt.Prompt +): Descriptor.Command> => { + const op = Object.create(proto) + op._tag = "GetUserInput" + op.name = name + op.description = InternalHelpDoc.empty + op.prompt = prompt + return op +} + +// ============================================================================= +// Combinators +// ============================================================================= + +/** @internal */ +export const getHelp = (self: Descriptor.Command): HelpDoc.HelpDoc => + getHelpInternal(self as Instruction) + +/** @internal */ +export const getNames = (self: Descriptor.Command): HashSet.HashSet => + getNamesInternal(self as Instruction) + +/** @internal */ +export const getBashCompletions = ( + self: Descriptor.Command, + programName: string +): Effect.Effect> => + getBashCompletionsInternal(self as Instruction, programName) + +/** @internal */ +export const getFishCompletions = ( + self: Descriptor.Command, + programName: string +): Effect.Effect> => + getFishCompletionsInternal(self as Instruction, programName) + +/** @internal */ +export const getZshCompletions = ( + self: Descriptor.Command, + programName: string +): Effect.Effect> => + getZshCompletionsInternal(self as Instruction, programName) + +/** @internal */ +export const getSubcommands = ( + self: Descriptor.Command +): HashMap.HashMap> => + getSubcommandsInternal(self as Instruction) + +/** @internal */ +export const getUsage = (self: Descriptor.Command): Usage.Usage => + getUsageInternal(self as Instruction) + +/** @internal */ +export const map = dual< + (f: (a: A) => B) => (self: Descriptor.Command) => Descriptor.Command, + (self: Descriptor.Command, f: (a: A) => B) => Descriptor.Command +>(2, (self, f) => mapOrFail(self, (a) => Either.right(f(a)))) + +/** @internal */ +export const mapOrFail = dual< + ( + f: (a: A) => Either.Either + ) => (self: Descriptor.Command) => Descriptor.Command, + ( + self: Descriptor.Command, + f: (a: A) => Either.Either + ) => Descriptor.Command +>(2, (self, f) => { + const op = Object.create(proto) + op._tag = "Map" + op.command = self + op.f = f + return op +}) + +/** @internal */ +export const orElse = dual< + ( + that: Descriptor.Command + ) => (self: Descriptor.Command) => Descriptor.Command, + ( + self: Descriptor.Command, + that: Descriptor.Command + ) => Descriptor.Command +>(2, (self, that) => { + const op = Object.create(proto) + op._tag = "OrElse" + op.left = self + op.right = that + return op +}) + +/** @internal */ +export const orElseEither = dual< + ( + that: Descriptor.Command + ) => (self: Descriptor.Command) => Descriptor.Command>, + ( + self: Descriptor.Command, + that: Descriptor.Command + ) => Descriptor.Command> +>(2, (self, that) => orElse(map(self, Either.left), map(that, Either.right))) + +/** @internal */ +export const parse = dual< + ( + args: ReadonlyArray, + config: CliConfig.CliConfig + ) => (self: Descriptor.Command) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + Directive.CommandDirective + >, + ( + self: Descriptor.Command, + args: ReadonlyArray, + config: CliConfig.CliConfig + ) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + Directive.CommandDirective + > +>(3, (self, args, config) => parseInternal(self as Instruction, args, config)) + +/** @internal */ +export const withDescription = dual< + ( + help: string | HelpDoc.HelpDoc + ) => (self: Descriptor.Command) => Descriptor.Command, + ( + self: Descriptor.Command, + help: string | HelpDoc.HelpDoc + ) => Descriptor.Command +>(2, (self, help) => withDescriptionInternal(self as Instruction, help)) + +/** @internal */ +export const withSubcommands = dual< + < + Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< + readonly [id: unknown, command: Descriptor.Command] + > + >( + subcommands: [...Subcommands] + ) => ( + self: Descriptor.Command + ) => Descriptor.Command< + Descriptor.Command.ComputeParsedType< + & A + & Readonly<{ subcommand: Option.Option> }> + > + >, + < + A, + Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< + readonly [id: unknown, command: Descriptor.Command] + > + >( + self: Descriptor.Command, + subcommands: [...Subcommands] + ) => Descriptor.Command< + Descriptor.Command.ComputeParsedType< + & A + & Readonly<{ subcommand: Option.Option> }> + > + > +>(2, (self, subcommands) => { + const op = Object.create(proto) + op._tag = "Subcommands" + op.parent = self + if (ReadonlyArray.isNonEmptyReadonlyArray(subcommands)) { + const mapped = ReadonlyArray.map( + subcommands, + ([id, command]) => map(command, (a) => [id, a] as const) + ) + const head = ReadonlyArray.headNonEmpty>(mapped) + const tail = ReadonlyArray.tailNonEmpty>(mapped) + op.child = ReadonlyArray.isNonEmptyReadonlyArray(tail) + ? ReadonlyArray.reduce(tail, head, orElse) + : head + return op + } + throw new Error("[BUG]: Command.subcommands - received empty list of subcommands") +}) + +/** @internal */ +export const wizard = dual< + (config: CliConfig.CliConfig) => (self: Descriptor.Command) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + ReadonlyArray + >, + (self: Descriptor.Command, config: CliConfig.CliConfig) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + ReadonlyArray + > +>(2, (self, config) => wizardInternal(self as Instruction, config)) + +// ============================================================================= +// Internals +// ============================================================================= + +const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { + switch (self._tag) { + case "Standard": { + const header = InternalHelpDoc.isEmpty(self.description) + ? InternalHelpDoc.empty + : InternalHelpDoc.sequence(InternalHelpDoc.h1("DESCRIPTION"), self.description) + const argsHelp = InternalArgs.getHelp(self.args) + const argsSection = InternalHelpDoc.isEmpty(argsHelp) + ? InternalHelpDoc.empty + : InternalHelpDoc.sequence(InternalHelpDoc.h1("ARGUMENTS"), argsHelp) + const optionsHelp = InternalOptions.getHelp(self.options) + const optionsSection = InternalHelpDoc.isEmpty(optionsHelp) + ? InternalHelpDoc.empty + : InternalHelpDoc.sequence(InternalHelpDoc.h1("OPTIONS"), optionsHelp) + return InternalHelpDoc.sequence(header, InternalHelpDoc.sequence(argsSection, optionsSection)) + } + case "GetUserInput": { + return InternalHelpDoc.isEmpty(self.description) + ? InternalHelpDoc.empty + : InternalHelpDoc.sequence(InternalHelpDoc.h1("DESCRIPTION"), self.description) + } + case "Map": { + return getHelpInternal(self.command as Instruction) + } + case "OrElse": { + return InternalHelpDoc.sequence( + getHelpInternal(self.left as Instruction), + getHelpInternal(self.right as Instruction) + ) + } + case "Subcommands": { + const getUsage = ( + command: Instruction, + preceding: ReadonlyArray + ): ReadonlyArray<[Span.Span, Span.Span]> => { + switch (command._tag) { + case "Standard": + case "GetUserInput": { + const usage = InternalHelpDoc.getSpan(InternalUsage.getHelp(getUsageInternal(command))) + const usages = ReadonlyArray.append(preceding, usage) + const finalUsage = ReadonlyArray.reduce( + usages, + InternalSpan.empty, + (acc, next) => + InternalSpan.isText(acc) && acc.value === "" + ? next + : InternalSpan.isText(next) && next.value === "" + ? acc + : InternalSpan.spans([acc, InternalSpan.space, next]) + ) + const description = InternalHelpDoc.getSpan(command.description) + return ReadonlyArray.of([finalUsage, description]) + } + case "Map": { + return getUsage(command.command as Instruction, preceding) + } + case "OrElse": { + return ReadonlyArray.appendAll( + getUsage(command.left as Instruction, preceding), + getUsage(command.right as Instruction, preceding) + ) + } + case "Subcommands": { + const parentUsage = getUsage(command.parent as Instruction, preceding) + return Option.match(ReadonlyArray.head(parentUsage), { + onNone: () => getUsage(command.child as Instruction, preceding), + onSome: ([usage]) => { + const childUsage = getUsage( + command.child as Instruction, + ReadonlyArray.append(preceding, usage) + ) + return ReadonlyArray.appendAll(parentUsage, childUsage) + } + }) + } + } + } + const printSubcommands = ( + subcommands: ReadonlyArray<[Span.Span, Span.Span]> + ): HelpDoc.HelpDoc => { + const maxUsageLength = ReadonlyArray.reduceRight( + subcommands, + 0, + (max, [usage]) => Math.max(InternalSpan.size(usage), max) + ) + const documents = ReadonlyArray.map(subcommands, ([usage, desc]) => + InternalHelpDoc.p( + InternalSpan.spans([ + usage, + InternalSpan.text(" ".repeat(maxUsageLength - InternalSpan.size(usage) + 2)), + desc + ]) + )) + if (ReadonlyArray.isNonEmptyReadonlyArray(documents)) { + return InternalHelpDoc.enumeration(documents) + } + throw new Error("[BUG]: Subcommands.usage - received empty list of subcommands to print") + } + return InternalHelpDoc.sequence( + getHelpInternal(self.parent as Instruction), + InternalHelpDoc.sequence( + InternalHelpDoc.h1("COMMANDS"), + printSubcommands(getUsage(self.child as Instruction, ReadonlyArray.empty())) + ) + ) + } + } +} + +const getNamesInternal = (self: Instruction): HashSet.HashSet => { + switch (self._tag) { + case "Standard": + case "GetUserInput": { + return HashSet.make(self.name) + } + case "Map": { + return getNamesInternal(self.command as Instruction) + } + case "OrElse": { + return HashSet.union( + getNamesInternal(self.right as Instruction), + getNamesInternal(self.left as Instruction) + ) + } + case "Subcommands": { + return getNamesInternal(self.parent as Instruction) + } + } +} + +const getSubcommandsInternal = ( + self: Instruction +): HashMap.HashMap => { + switch (self._tag) { + case "Standard": + case "GetUserInput": { + return HashMap.make([self.name, self]) + } + case "Map": { + return getSubcommandsInternal(self.command as Instruction) + } + case "OrElse": { + return HashMap.union( + getSubcommandsInternal(self.left as Instruction), + getSubcommandsInternal(self.right as Instruction) + ) + } + case "Subcommands": { + return getSubcommandsInternal(self.parent as Instruction) + } + } +} + +const getUsageInternal = (self: Instruction): Usage.Usage => { + switch (self._tag) { + case "Standard": { + return InternalUsage.concat( + InternalUsage.named(ReadonlyArray.of(self.name), Option.none()), + InternalUsage.concat( + InternalOptions.getUsage(self.options), + InternalArgs.getUsage(self.args) + ) + ) + } + case "GetUserInput": { + return InternalUsage.named(ReadonlyArray.of(self.name), Option.none()) + } + case "Map": { + return getUsageInternal(self.command as Instruction) + } + case "OrElse": { + return InternalUsage.mixed + } + case "Subcommands": { + return InternalUsage.concat( + getUsageInternal(self.parent as Instruction), + getUsageInternal(self.child as Instruction) + ) + } + } +} + +const parseInternal = ( + self: Instruction, + args: ReadonlyArray, + config: CliConfig.CliConfig +): Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + Directive.CommandDirective +> => { + switch (self._tag) { + case "Standard": { + const parseCommandLine = ( + args: ReadonlyArray + ): Effect.Effect> => { + if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { + const head = ReadonlyArray.headNonEmpty(args) + const tail = ReadonlyArray.tailNonEmpty(args) + const normalizedArgv0 = InternalCliConfig.normalizeCase(config, head) + const normalizedCommandName = InternalCliConfig.normalizeCase(config, self.name) + return Effect.succeed(tail).pipe( + Effect.when(() => normalizedArgv0 === normalizedCommandName), + Effect.flatten, + Effect.catchTag("NoSuchElementException", () => { + const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) + return Effect.fail(InternalValidationError.commandMismatch(error)) + }) + ) + } + const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) + return Effect.fail(InternalValidationError.commandMismatch(error)) + } + const parseBuiltInArgs = ( + args: ReadonlyArray + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + Directive.CommandDirective + > => { + if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { + const argv0 = ReadonlyArray.headNonEmpty(args) + const normalizedArgv0 = InternalCliConfig.normalizeCase(config, argv0) + const normalizedCommandName = InternalCliConfig.normalizeCase(config, self.name) + if (normalizedArgv0 === normalizedCommandName) { + const help = getHelpInternal(self) + const usage = getUsageInternal(self) + const options = InternalBuiltInOptions.builtInOptions(self, usage, help) + return InternalOptions.validate(options, ReadonlyArray.drop(args, 1), config).pipe( + Effect.flatMap((tuple) => tuple[2]), + Effect.catchTag("NoSuchElementException", () => { + const error = InternalHelpDoc.p("No built-in option was matched") + return Effect.fail(InternalValidationError.noBuiltInMatch(error)) + }), + Effect.map(InternalCommandDirective.builtIn) + ) + } + } + const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) + return Effect.fail(InternalValidationError.commandMismatch(error)) + } + const parseUserDefinedArgs = ( + args: ReadonlyArray + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + Directive.CommandDirective + > => + parseCommandLine(args).pipe(Effect.flatMap((commandOptionsAndArgs) => { + const [optionsAndArgs, forcedCommandArgs] = splitForcedArgs(commandOptionsAndArgs) + return InternalOptions.validate(self.options, optionsAndArgs, config).pipe( + Effect.flatMap(([error, commandArgs, optionsType]) => + InternalArgs.validate( + self.args, + ReadonlyArray.appendAll(commandArgs, forcedCommandArgs), + config + ).pipe( + Effect.catchAll((e) => + Option.match(error, { + onNone: () => Effect.fail(e), + onSome: (err) => Effect.fail(err) + }) + ), + Effect.map(([argsLeftover, argsType]) => + InternalCommandDirective.userDefined(argsLeftover, { + name: self.name, + options: optionsType, + args: argsType + }) + ) + ) + ) + ) + })) + const exhaustiveSearch = ( + args: ReadonlyArray + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + Directive.CommandDirective + > => { + if (ReadonlyArray.contains(args, "--help") || ReadonlyArray.contains(args, "-h")) { + return parseBuiltInArgs(ReadonlyArray.make(self.name, "--help")) + } + if (ReadonlyArray.contains(args, "--wizard")) { + return parseBuiltInArgs(ReadonlyArray.make(self.name, "--wizard")) + } + if (ReadonlyArray.contains(args, "--version")) { + return parseBuiltInArgs(ReadonlyArray.make(self.name, "--version")) + } + const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) + return Effect.fail(InternalValidationError.commandMismatch(error)) + } + return parseBuiltInArgs(args).pipe( + Effect.orElse(() => parseUserDefinedArgs(args)), + Effect.catchSome((e) => { + if (InternalValidationError.isValidationError(e)) { + if (config.finalCheckBuiltIn) { + return Option.some( + exhaustiveSearch(args).pipe( + Effect.catchSome((_) => + InternalValidationError.isValidationError(_) + ? Option.some(Effect.fail(e)) + : Option.none() + ) + ) + ) + } + return Option.some(Effect.fail(e)) + } + return Option.none() + }) + ) + } + case "GetUserInput": { + return InternalPrompt.run(self.prompt).pipe( + Effect.map((value) => + InternalCommandDirective.userDefined(ReadonlyArray.drop(args, 1), { + name: self.name, + value + }) + ) + ) + } + case "Map": { + return parseInternal(self.command as Instruction, args, config).pipe( + Effect.flatMap((directive) => { + if (InternalCommandDirective.isUserDefined(directive)) { + const either = self.f(directive.value) + return Either.isLeft(either) + ? Effect.fail(either.left) + : Effect.succeed(InternalCommandDirective.userDefined( + directive.leftover, + either.right + )) + } + return Effect.succeed(directive) + }) + ) + } + case "OrElse": { + return parseInternal(self.left as Instruction, args, config).pipe( + Effect.catchSome((e) => { + return InternalValidationError.isCommandMismatch(e) + ? Option.some(parseInternal(self.right as Instruction, args, config)) + : Option.none() + }) + ) + } + case "Subcommands": { + const names = Array.from(getNamesInternal(self)) + const subcommands = getSubcommandsInternal(self.child as Instruction) + const [parentArgs, childArgs] = ReadonlyArray.span( + args, + (name) => !HashMap.has(subcommands, name) + ) + const helpDirectiveForParent = Effect.sync(() => { + return InternalCommandDirective.builtIn(InternalBuiltInOptions.showHelp( + getUsageInternal(self), + getHelpInternal(self) + )) + }) + 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 = 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)) { + // 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) + } + return Effect.succeed(directive) + } + case "UserDefined": { + const args = ReadonlyArray.appendAll(directive.leftover, childArgs) + if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { + return parseInternal(self.child as Instruction, args, config).pipe(Effect.mapBoth({ + onFailure: (err) => { + if (InternalValidationError.isCommandMismatch(err)) { + const parentName = Option.getOrElse(ReadonlyArray.head(names), () => "") + const subcommandNames = pipe( + ReadonlyArray.fromIterable(HashMap.keys(subcommands)), + ReadonlyArray.map((name) => `'${name}'`) + ) + const oneOf = subcommandNames.length === 1 ? "" : " one of" + const error = InternalHelpDoc.p( + `Invalid subcommand for ${parentName} - use${oneOf} ${ + ReadonlyArray.join(subcommandNames, ", ") + }` + ) + return InternalValidationError.commandMismatch(error) + } + return err + }, + onSuccess: InternalCommandDirective.map((subcommand) => ({ + ...directive.value as any, + subcommand: Option.some(subcommand) + })) + })) + } + return Effect.succeed(InternalCommandDirective.userDefined(directive.leftover, { + ...directive.value as any, + subcommand: Option.none() + })) + } + } + }), + Effect.catchSome(() => + ReadonlyArray.isEmptyReadonlyArray(args) + ? Option.some(helpDirectiveForParent) : + Option.none() + ) + ) + } + } +} + +const splitForcedArgs = ( + args: ReadonlyArray +): [ReadonlyArray, ReadonlyArray] => { + const [remainingArgs, forcedArgs] = ReadonlyArray.span(args, (str) => str !== "--") + return [remainingArgs, ReadonlyArray.drop(forcedArgs, 1)] +} + +const withDescriptionInternal = ( + self: Instruction, + description: string | HelpDoc.HelpDoc +): Descriptor.Command => { + switch (self._tag) { + case "Standard": { + const helpDoc = typeof description === "string" ? HelpDoc.p(description) : description + const op = Object.create(proto) + op._tag = "Standard" + op.name = self.name + op.description = helpDoc + op.options = self.options + op.args = self.args + return op + } + case "GetUserInput": { + const helpDoc = typeof description === "string" ? HelpDoc.p(description) : description + const op = Object.create(proto) + op._tag = "GetUserInput" + op.name = self.name + op.description = helpDoc + op.prompt = self.prompt + return op + } + case "Map": { + return map(withDescriptionInternal(self.command as Instruction, description), self.f) + } + case "OrElse": { + // TODO: if both the left and right commands also have help defined, that + // help will be overwritten by this method which may be undesirable + return orElse( + withDescriptionInternal(self.left as Instruction, description), + withDescriptionInternal(self.right as Instruction, description) + ) + } + case "Subcommands": { + const op = Object.create(proto) + op._tag = "Subcommands" + op.parent = withDescriptionInternal(self.parent as Instruction, description) + op.child = self.child + return op + } + } +} + +const wizardInternal = ( + self: Instruction, + config: CliConfig.CliConfig +): Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + ValidationError.ValidationError, + ReadonlyArray +> => { + 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 { _tag: "SingleCommandWizard", command: self } + } + case "Map": { + return getWizardCommandSequence(self.command as Instruction) + } + case "OrElse": { + 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 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 } + } + } +} + +// ============================================================================= +// Completion Internals +// ============================================================================= + +const getShortDescription = (self: Instruction): string => { + switch (self._tag) { + case "Standard": { + return InternalSpan.getText(InternalHelpDoc.getSpan(self.description)) + } + case "GetUserInput": { + return InternalSpan.getText(InternalHelpDoc.getSpan(self.description)) + } + case "Map": { + return getShortDescription(self.command as Instruction) + } + case "OrElse": + case "Subcommands": { + return "" + } + } +} + +interface CommandInfo { + readonly command: Standard | GetUserInput + readonly parentCommands: ReadonlyArray + readonly subcommands: ReadonlyArray<[string, Standard | GetUserInput]> + readonly level: number +} + +/** + * Allows for linear traversal of a `Command` data structure, accumulating state + * based on information acquired from the command. + */ +const traverseCommand = ( + self: Instruction, + initialState: S, + f: (state: S, info: CommandInfo) => Effect.Effect +): Effect.Effect => + SynchronizedRef.make(initialState).pipe(Effect.flatMap((ref) => { + const loop = ( + self: Instruction, + parentCommands: ReadonlyArray, + subcommands: ReadonlyArray<[string, Standard | GetUserInput]>, + level: number + ): Effect.Effect => { + switch (self._tag) { + case "Standard": { + const info: CommandInfo = { + command: self, + parentCommands, + subcommands, + level + } + return SynchronizedRef.updateEffect(ref, (state) => f(state, info)) + } + case "GetUserInput": { + const info: CommandInfo = { + command: self, + parentCommands, + subcommands, + level + } + return SynchronizedRef.updateEffect(ref, (state) => f(state, info)) + } + case "Map": { + return loop(self.command as Instruction, parentCommands, subcommands, level) + } + case "OrElse": { + const left = loop(self.left as Instruction, parentCommands, subcommands, level) + const right = loop(self.right as Instruction, parentCommands, subcommands, level) + return Effect.zipRight(left, right) + } + case "Subcommands": { + const parentNames = Array.from(getNamesInternal(self.parent as Instruction)) + const nextSubcommands = Array.from(getSubcommandsInternal(self.child as Instruction)) + const nextParentCommands = ReadonlyArray.appendAll(parentCommands, parentNames) + // Traverse the parent command using old parent names and next subcommands + return loop(self.parent as Instruction, parentCommands, nextSubcommands, level).pipe( + Effect.zipRight( + // Traverse the child command using next parent names and old subcommands + loop(self.child as Instruction, nextParentCommands, subcommands, level + 1) + ) + ) + } + } + } + return Effect.suspend(() => loop(self, ReadonlyArray.empty(), ReadonlyArray.empty(), 0)).pipe( + Effect.zipRight(SynchronizedRef.get(ref)) + ) + })) + +const indentAll = dual< + (indent: number) => (self: ReadonlyArray) => ReadonlyArray, + (self: ReadonlyArray, indent: number) => ReadonlyArray +>(2, (self: ReadonlyArray, indent: number): ReadonlyArray => { + const indentation = new Array(indent + 1).join(" ") + return ReadonlyArray.map(self, (line) => `${indentation}${line}`) +}) + +const getBashCompletionsInternal = ( + self: Instruction, + rootCommand: string +): Effect.Effect> => + traverseCommand( + self, + ReadonlyArray.empty<[ReadonlyArray, ReadonlyArray]>(), + (state, info) => { + const options = isStandard(info.command) + ? Options.all([info.command.options, InternalBuiltInOptions.builtIns]) + : InternalBuiltInOptions.builtIns + const optionNames = InternalOptions.getNames(options as InternalOptions.Instruction) + const optionCases = isStandard(info.command) + ? InternalOptions.getBashCompletions(info.command.options as InternalOptions.Instruction) + : ReadonlyArray.empty() + const subcommandNames = pipe( + info.subcommands, + ReadonlyArray.map(([name]) => name), + ReadonlyArray.sort(Order.string) + ) + const wordList = ReadonlyArray.appendAll(optionNames, subcommandNames) + const preformatted = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) + ? ReadonlyArray.of(info.command.name) + : pipe( + info.parentCommands, + ReadonlyArray.append(info.command.name), + ReadonlyArray.map((command) => command.replace("-", "__")) + ) + const caseName = ReadonlyArray.join(preformatted, ",") + const funcName = ReadonlyArray.join(preformatted, "__") + const funcLines = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) + ? ReadonlyArray.empty() + : [ + `${caseName})`, + ` cmd="${funcName}"`, + " ;;" + ] + const cmdLines = [ + `${funcName})`, + ` opts="${ReadonlyArray.join(wordList, " ")}"`, + ` if [[ \${cur} == -* || \${COMP_CWORD} -eq ${info.level + 1} ]] ; then`, + " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )", + " return 0", + " fi", + " case \"${prev}\" in", + ...indentAll(optionCases, 8), + " *)", + " COMPREPLY=()", + " ;;", + " esac", + " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )", + " return 0", + " ;;" + ] + const lines = ReadonlyArray.append( + state, + [funcLines, cmdLines] as [ReadonlyArray, ReadonlyArray] + ) + return Effect.succeed(lines) + } + ).pipe(Effect.map((lines) => { + const scriptName = `_${rootCommand}_bash_completions` + const funcCases = ReadonlyArray.flatMap(lines, ([funcLines]) => funcLines) + const cmdCases = ReadonlyArray.flatMap(lines, ([, cmdLines]) => cmdLines) + return [ + `function ${scriptName}() {`, + " local i cur prev opts cmd", + " COMPREPLY=()", + " cur=\"${COMP_WORDS[COMP_CWORD]}\"", + " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"", + " cmd=\"\"", + " opts=\"\"", + " for i in \"${COMP_WORDS[@]}\"; do", + " case \"${cmd},${i}\" in", + " \",$1\")", + ` cmd="${rootCommand}"`, + " ;;", + ...indentAll(funcCases, 12), + " *)", + " ;;", + " esac", + " done", + " case \"${cmd}\" in", + ...indentAll(cmdCases, 8), + " esac", + "}", + `complete -F ${scriptName} -o nosort -o bashdefault -o default ${rootCommand}` + ] + })) + +const getFishCompletionsInternal = ( + self: Instruction, + rootCommand: string +): Effect.Effect> => + traverseCommand(self, ReadonlyArray.empty(), (state, info) => { + const baseTemplate = ReadonlyArray.make("complete", "-c", rootCommand) + const options = isStandard(info.command) + ? InternalOptions.all([InternalBuiltInOptions.builtIns, info.command.options]) + : InternalBuiltInOptions.builtIns + const optionsCompletions = InternalOptions.getFishCompletions( + options as InternalOptions.Instruction + ) + const argsCompletions = isStandard(info.command) + ? InternalArgs.getFishCompletions(info.command.args as InternalArgs.Instruction) + : ReadonlyArray.empty() + const rootCompletions = (conditionals: ReadonlyArray) => + pipe( + ReadonlyArray.map(optionsCompletions, (option) => + pipe( + baseTemplate, + ReadonlyArray.appendAll(conditionals), + ReadonlyArray.append(option), + ReadonlyArray.join(" ") + )), + ReadonlyArray.appendAll( + ReadonlyArray.map(argsCompletions, (option) => + pipe( + baseTemplate, + ReadonlyArray.appendAll(conditionals), + ReadonlyArray.append(option), + ReadonlyArray.join(" ") + )) + ) + ) + const subcommandCompletions = (conditionals: ReadonlyArray) => + ReadonlyArray.map(info.subcommands, ([name, subcommand]) => { + const description = getShortDescription(subcommand) + return pipe( + baseTemplate, + ReadonlyArray.appendAll(conditionals), + ReadonlyArray.appendAll(ReadonlyArray.make("-f", "-a", `"${name}"`)), + ReadonlyArray.appendAll( + description.length === 0 + ? ReadonlyArray.empty() + : ReadonlyArray.make("-d", `'${description}'`) + ), + ReadonlyArray.join(" ") + ) + }) + // If parent commands are empty, then the info is for the root command + if (ReadonlyArray.isEmptyReadonlyArray(info.parentCommands)) { + const conditionals = ReadonlyArray.make("-n", "\"__fish_use_subcommand\"") + return Effect.succeed(pipe( + state, + ReadonlyArray.appendAll(rootCompletions(conditionals)), + ReadonlyArray.appendAll(subcommandCompletions(conditionals)) + )) + } + // Otherwise the info is for a subcommand + const parentConditionals = pipe( + info.parentCommands, + // Drop the root command name from the subcommand conditionals + ReadonlyArray.drop(1), + ReadonlyArray.append(info.command.name), + ReadonlyArray.map((command) => `__fish_seen_subcommand_from ${command}`) + ) + const subcommandConditionals = ReadonlyArray.map( + info.subcommands, + ([name]) => `not __fish_seen_subcommand_from ${name}` + ) + const baseConditionals = pipe( + ReadonlyArray.appendAll(parentConditionals, subcommandConditionals), + ReadonlyArray.join("; and ") + ) + const conditionals = ReadonlyArray.make("-n", `"${baseConditionals}"`) + return Effect.succeed(pipe( + state, + ReadonlyArray.appendAll(rootCompletions(conditionals)), + ReadonlyArray.appendAll(subcommandCompletions(conditionals)) + )) + }) + +const getZshCompletionsInternal = ( + self: Instruction, + rootCommand: string +): Effect.Effect> => + traverseCommand(self, ReadonlyArray.empty(), (state, info) => { + const preformatted = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands) + ? ReadonlyArray.of(info.command.name) + : pipe( + info.parentCommands, + ReadonlyArray.append(info.command.name), + ReadonlyArray.map((command) => command.replace("-", "__")) + ) + const underscoreName = ReadonlyArray.join(preformatted, "__") + const spaceName = ReadonlyArray.join(preformatted, " ") + const subcommands = pipe( + info.subcommands, + ReadonlyArray.map(([name, subcommand]) => { + const desc = getShortDescription(subcommand) + return `'${name}:${desc}' \\` + }) + ) + const commands = ReadonlyArray.isEmptyReadonlyArray(subcommands) + ? `commands=()` + : `commands=(\n${ReadonlyArray.join(indentAll(subcommands, 8), "\n")}\n )` + const handlerLines = [ + `(( $+functions[_${underscoreName}_commands] )) ||`, + `_${underscoreName}_commands() {`, + ` local commands; ${commands}`, + ` _describe -t commands '${spaceName} commands' commands "$@"`, + "}" + ] + return Effect.succeed(ReadonlyArray.appendAll(state, handlerLines)) + }).pipe(Effect.map((handlers) => { + const cases = getZshSubcommandCases(self, ReadonlyArray.empty(), ReadonlyArray.empty()) + const scriptName = `_${rootCommand}_zsh_completions` + return [ + `#compdef ${rootCommand}`, + "", + "autoload -U is-at-least", + "", + `function ${scriptName}() {`, + " typeset -A opt_args", + " typeset -a _arguments_options", + " local ret=1", + "", + " if is-at-least 5.2; then", + " _arguments_options=(-s -S -C)", + " else", + " _arguments_options=(-s -C)", + " fi", + "", + " local context curcontext=\"$curcontext\" state line", + ...indentAll(cases, 4), + "}", + "", + ...handlers, + "", + `if [ "$funcstack[1]" = "${scriptName}" ]; then`, + ` ${scriptName} "$@"`, + "else", + ` compdef ${scriptName} ${rootCommand}`, + "fi" + ] + })) + +const getZshSubcommandCases = ( + self: Instruction, + parentCommands: ReadonlyArray, + subcommands: ReadonlyArray<[string, Standard | GetUserInput]> +): ReadonlyArray => { + switch (self._tag) { + case "Standard": + case "GetUserInput": { + const options = isStandard(self) + ? InternalOptions.all([InternalBuiltInOptions.builtIns, self.options]) + : InternalBuiltInOptions.builtIns + const optionCompletions = pipe( + InternalOptions.getZshCompletions(options as InternalOptions.Instruction), + ReadonlyArray.map((completion) => `'${completion}' \\`) + ) + if (ReadonlyArray.isEmptyReadonlyArray(parentCommands)) { + return [ + "_arguments \"${_arguments_options[@]}\" \\", + ...indentAll(optionCompletions, 4), + ` ":: :_${self.name}_commands" \\`, + ` "*::: :->${self.name}" \\`, + " && ret=0" + ] + } + if (ReadonlyArray.isEmptyReadonlyArray(subcommands)) { + return [ + `(${self.name})`, + "_arguments \"${_arguments_options[@]}\" \\", + ...indentAll(optionCompletions, 4), + " && ret=0", + ";;" + ] + } + return [ + `(${self.name})`, + "_arguments \"${_arguments_options[@]}\" \\", + ...indentAll(optionCompletions, 4), + ` ":: :_${ReadonlyArray.append(parentCommands, self.name).join("__")}_commands" \\`, + ` "*::: :->${self.name}" \\`, + " && ret=0" + ] + } + case "Map": { + return getZshSubcommandCases(self.command as Instruction, parentCommands, subcommands) + } + case "OrElse": { + const left = getZshSubcommandCases(self.left as Instruction, parentCommands, subcommands) + const right = getZshSubcommandCases(self.right as Instruction, parentCommands, subcommands) + return ReadonlyArray.appendAll(left, right) + } + case "Subcommands": { + const nextSubcommands = Array.from(getSubcommandsInternal(self.child as Instruction)) + const parentNames = Array.from(getNamesInternal(self.parent as Instruction)) + const parentLines = getZshSubcommandCases( + self.parent as Instruction, + parentCommands, + ReadonlyArray.appendAll(subcommands, nextSubcommands) + ) + const childCases = getZshSubcommandCases( + self.child as Instruction, + ReadonlyArray.appendAll(parentCommands, parentNames), + subcommands + ) + const hyphenName = pipe( + ReadonlyArray.appendAll(parentCommands, parentNames), + ReadonlyArray.join("-") + ) + const childLines = pipe( + parentNames, + ReadonlyArray.flatMap((parentName) => [ + "case $state in", + ` (${parentName})`, + ` words=($line[1] "\${words[@]}")`, + " (( CURRENT += 1 ))", + ` curcontext="\${curcontext%:*:*}:${hyphenName}-command-$line[1]:"`, + ` case $line[1] in`, + ...indentAll(childCases, 8), + " esac", + " ;;", + "esac" + ]), + ReadonlyArray.appendAll( + ReadonlyArray.isEmptyReadonlyArray(parentCommands) + ? ReadonlyArray.empty() + : ReadonlyArray.of(";;") + ) + ) + return ReadonlyArray.appendAll(parentLines, childLines) + } + } +} + +// Circular with ValidationError + +/** @internal */ +export const helpRequestedError = ( + command: Descriptor.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 +} From c6272ac218bc07b3133c3a3c73d74805e41270c5 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 15:45:21 +1300 Subject: [PATCH 16/39] update prompt example --- examples/prompt.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/prompt.ts b/examples/prompt.ts index 4f2e450..ddb62d7 100644 --- a/examples/prompt.ts +++ b/examples/prompt.ts @@ -1,5 +1,4 @@ -import * as CliApp from "@effect/cli/CliApp" -import * as Command from "@effect/cli/CommandDescriptor" +import * as Command from "@effect/cli/Command" import * as Prompt from "@effect/cli/Prompt" import * as NodeContext from "@effect/platform-node/NodeContext" import * as Runtime from "@effect/platform-node/Runtime" @@ -57,14 +56,12 @@ const prompt = Prompt.all([ togglePrompt ]) -const cli = CliApp.make({ +const cli = Command.run(Command.prompt("favorites", prompt, Effect.log), { name: "Prompt Examples", - version: "0.0.1", - command: Command.prompt("favorites", prompt) + version: "0.0.1" }) -Effect.sync(() => process.argv.slice(2)).pipe( - Effect.flatMap((args) => CliApp.run(cli, args, (input) => Effect.log(input))), +Effect.suspend(() => cli(process.argv.slice(2))).pipe( Effect.provide(NodeContext.layer), Runtime.runMain ) From 957f153495e04e337f16066649a26abd4a8dd1a7 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 15:56:59 +1300 Subject: [PATCH 17/39] update minigit example --- examples/minigit.ts | 32 +++++++++++++++++++++++++------- src/Command.ts | 17 +++++++++++++---- src/internal/command.ts | 1 - 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/examples/minigit.ts b/examples/minigit.ts index 57c6c1f..3579a7a 100644 --- a/examples/minigit.ts +++ b/examples/minigit.ts @@ -1,5 +1,5 @@ import * as Args from "@effect/cli/Args" -import * as Handled from "@effect/cli/Command" +import * as Command from "@effect/cli/Command" import * as Options from "@effect/cli/Options" import * as NodeContext from "@effect/platform-node/NodeContext" import * as Runtime from "@effect/platform-node/Runtime" @@ -10,7 +10,7 @@ import * as Option from "effect/Option" import * as ReadonlyArray from "effect/ReadonlyArray" // minigit [--version] [-h | --help] [-c =] -const minigit = Handled.make( +const minigit = Command.make( "minigit", { configs: Options.keyValueMap("c").pipe(Options.optional) }, ({ configs }) => @@ -25,13 +25,31 @@ const minigit = Handled.make( }) ) +const configsString = Effect.map( + minigit, + ({ configs }) => + Option.match(configs, { + onNone: () => "", + onSome: (configs) => { + const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join( + ", " + ) + return ` with the following configs: ${keyValuePairs}` + } + }) +) + // minigit add [-v | --verbose] [--] [...] -const minigitAdd = Handled.make("add", { +const minigitAdd = Command.make("add", { verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) -}, ({ verbose }) => Console.log(`Running 'minigit add' with '--verbose ${verbose}'`)) +}, ({ verbose }) => + Effect.gen(function*(_) { + const configs = yield* _(configsString) + yield* _(Console.log(`Running 'minigit add' with '--verbose ${verbose}'${configs}`)) + })) // minigit clone [--depth ] [--] [] -const minigitClone = Handled.make("clone", { +const minigitClone = Command.make("clone", { repository: Args.text({ name: "repository" }), directory: Args.directory().pipe(Args.optional), depth: Options.integer("depth").pipe(Options.optional) @@ -49,13 +67,13 @@ const minigitClone = Handled.make("clone", { ) }) -const finalCommand = minigit.pipe(Handled.withSubcommands([minigitAdd, minigitClone])) +const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone])) // ============================================================================= // Application // ============================================================================= -const run = Handled.run(finalCommand, { +const run = Command.run(finalCommand, { name: "MiniGit Distributed Version Control", version: "v2.42.1" }) diff --git a/src/Command.ts b/src/Command.ts index 0d09606..d8f0189 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -33,12 +33,12 @@ export type TypeId = typeof TypeId * @category models */ export interface Command - extends Pipeable, Effect, never, A> + extends Pipeable, Effect, never, A> { readonly [TypeId]: TypeId readonly descriptor: Descriptor.Command readonly handler: (_: A) => Effect - readonly tag: Tag, A> + readonly tag: Tag, A> } /** @@ -46,6 +46,15 @@ export interface Command * @category models */ export declare namespace Command { + /** + * @since 1.0.0 + * @category models + */ + export interface Context { + readonly _: unique symbol + readonly name: Name + } + /** * @since 1.0.0 * @category models @@ -237,7 +246,7 @@ export const withSubcommands: { ) => Command< Name, | R - | Exclude>, Descriptor.Command>, + | Exclude>, Command.Context>, E | Effect.Error>, Descriptor.Command.ComputeParsedType< & A @@ -258,7 +267,7 @@ export const withSubcommands: { ): Command< Name, | R - | Exclude>, Descriptor.Command>, + | Exclude>, Command.Context>, E | Effect.Error>, Descriptor.Command.ComputeParsedType< & A diff --git a/src/internal/command.ts b/src/internal/command.ts index bb97a76..24037b0 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -131,7 +131,6 @@ const makeProto = ( handler: (_: A) => Effect.Effect, tag?: Context.Tag ): Command.Command => { - console.log("makeProto", descriptor, handler, tag) const self = Object.create(Prototype) self.descriptor = descriptor self.handler = handler From ca74efbf71563b564f76f77ea62f33014a6f7d6f Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 16:09:13 +1300 Subject: [PATCH 18/39] changeset --- .changeset/fresh-dingos-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-dingos-crash.md diff --git a/.changeset/fresh-dingos-crash.md b/.changeset/fresh-dingos-crash.md new file mode 100644 index 0000000..22cd33d --- /dev/null +++ b/.changeset/fresh-dingos-crash.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": minor +--- + +add localized handlers for Command's From 695f8f69185eca3b4da46c17c40c36b77e867d86 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 16:31:15 +1300 Subject: [PATCH 19/39] remove help variants --- src/Command.ts | 29 +---------------------------- src/internal/command.ts | 29 ++++------------------------- 2 files changed, 5 insertions(+), 53 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index d8f0189..622c643 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -125,14 +125,6 @@ export const fromDescriptor: { ): Command } = Internal.fromDescriptor -/** - * @since 1.0.0 - * @category constructors - */ -export const fromDescriptorHelp: ( - descriptor: Descriptor.Command -) => Command = Internal.fromDescriptorHelp - /** * @since 1.0.0 * @category constructors @@ -156,31 +148,12 @@ export const make: > > = Internal.make -/** - * @since 1.0.0 - * @category constructors - */ -export const makeHelp: { - (name: Name): Command - ( - name: Name, - config: Config - ): Command< - Name, - never, - ValidationError, - Types.Simplify< - Types.Simplify<{ readonly [Key in keyof Config]: Command.ParseConfigValue }> - > - > -} = Internal.makeHelp - /** * @since 1.0.0 * @category constructors */ export const makeUnit: { - (name: Name): Command + (name: Name): Command ( name: Name, config: Config diff --git a/src/internal/command.ts b/src/internal/command.ts index 24037b0..1c5a00a 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -153,17 +153,12 @@ export const fromDescriptor = dual< /** @internal */ export const fromDescriptorUnit = ( descriptor: Descriptor.Command -): Command.Command => makeProto(descriptor, (_) => Effect.unit) - -/** @internal */ -export const fromDescriptorHelp = ( - descriptor: Descriptor.Command -): Command.Command => { +): Command.Command => { const self: Command.Command = makeProto( descriptor, - (_) => Effect.fail(ValidationError.helpRequested(getDescriptor(self))) + (_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(self))) ) - return self + return self as any } const makeDescriptor = ( @@ -193,7 +188,7 @@ export const make = ( name: Name - ): Command.Command + ): Command.Command ( name: Name, config: Config @@ -205,22 +200,6 @@ export const makeUnit: { > } = (name: string, config = {}) => fromDescriptorUnit(makeDescriptor(name, config) as any) as any -/** @internal */ -export const makeHelp: { - ( - name: Name - ): Command.Command - ( - name: Name, - config: Config - ): Command.Command< - Name, - never, - ValidationError.ValidationError, - Types.Simplify> - > -} = (name: string, config = {}) => fromDescriptorHelp(makeDescriptor(name, config) as any) as any - /** @internal */ export const mapDescriptor = dual< (f: (_: Descriptor.Command) => Descriptor.Command) => ( From 034bd00fbc005654efedc7cb968216def9535039 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 28 Nov 2023 16:57:01 +1300 Subject: [PATCH 20/39] fix withDefault types --- src/Args.ts | 4 ++-- src/Options.ts | 4 ++-- src/internal/args.ts | 10 +++++----- src/internal/options.ts | 9 ++++++--- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Args.ts b/src/Args.ts index ed81d5b..07edf4f 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -349,8 +349,8 @@ export const validate: { * @category combinators */ export const withDefault: { - (fallback: A): (self: Args) => Args - (self: Args, fallback: A): Args + (fallback: B): (self: Args) => Args + (self: Args, fallback: B): Args } = InternalArgs.withDefault /** diff --git a/src/Options.ts b/src/Options.ts index 6cc1889..c19a4ca 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -445,8 +445,8 @@ export const withAlias: { * @category combinators */ export const withDefault: { - (fallback: A): (self: Options) => Options - (self: Options, fallback: A): Options + (fallback: B): (self: Options) => Options + (self: Options, fallback: B): Options } = InternalOptions.withDefault /** diff --git a/src/internal/args.ts b/src/internal/args.ts index 9948567..ee0fba5 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -329,8 +329,8 @@ export const validate = dual< /** @internal */ export const withDefault = dual< - (fallback: A) => (self: Args.Args) => Args.Args, - (self: Args.Args, fallback: A) => Args.Args + (fallback: B) => (self: Args.Args) => Args.Args, + (self: Args.Args, fallback: B) => Args.Args >(2, (self, fallback) => makeWithDefault(self, fallback)) /** @internal */ @@ -571,10 +571,10 @@ const makeBoth = (left: Args.Args, right: Args.Args): Args.Args<[A, return op } -const makeWithDefault = ( +const makeWithDefault = ( self: Args.Args, - fallback: A -): Args.Args => { + fallback: B +): Args.Args => { const op = Object.create(proto) op._tag = "WithDefault" op.args = self diff --git a/src/internal/options.ts b/src/internal/options.ts index 2e2dc40..2ac6348 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -515,8 +515,8 @@ export const withAlias = dual< /** @internal */ export const withDefault = dual< - (fallback: A) => (self: Options.Options) => Options.Options, - (self: Options.Options, fallback: A) => Options.Options + (fallback: B) => (self: Options.Options) => Options.Options, + (self: Options.Options, fallback: B) => Options.Options >(2, (self, fallback) => makeWithDefault(self, fallback)) /** @internal */ @@ -906,7 +906,10 @@ const makeVariadic = ( return op } -const makeWithDefault = (options: Options.Options, fallback: A): Options.Options => { +const makeWithDefault = ( + options: Options.Options, + fallback: B +): Options.Options => { const op = Object.create(proto) op._tag = "WithDefault" op.options = options From 0dda63fb7a2b531b15c4b392bda318792e07996a Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 07:33:59 -0500 Subject: [PATCH 21/39] fix naval fate example --- examples/naval-fate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 11633ad..eb7c33a 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,7 +32,7 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const shipCommandParent = Command.makeHelp("ship", { +const shipCommandParent = Command.makeUnit("ship", { verbose: Options.boolean("verbose") }) @@ -73,7 +73,7 @@ const shipCommand = Command.withSubcommands(shipCommandParent, [ shootShipCommand ]) -const mineCommandParent = Command.makeHelp("mine") +const mineCommandParent = Command.makeUnit("mine") const setMineCommand = Command.make("set", { ...coordinatesArg, @@ -99,7 +99,7 @@ const mineCommand = Command.withSubcommands(mineCommandParent, [ removeMineCommand ]) -const run = Command.makeHelp("naval_fate").pipe( +const run = Command.makeUnit("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), Command.withSubcommands([shipCommand, mineCommand]), Command.run({ From 367b976604934479451a25e571e0b22920c843af Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 10:35:11 -0500 Subject: [PATCH 22/39] significantly improve wizard mode --- package.json | 3 + pnpm-lock.yaml | 166 +++++++++++++----------------- src/CommandDescriptor.ts | 8 +- src/Prompt.ts | 6 +- src/internal/args.ts | 59 +++++------ src/internal/cliApp.ts | 86 +++++++++------- src/internal/commandDescriptor.ts | 111 +++++++++++++------- src/internal/options.ts | 86 +++++++--------- src/internal/primitive.ts | 2 +- src/internal/prompt.ts | 7 +- 10 files changed, 276 insertions(+), 258 deletions(-) diff --git a/package.json b/package.json index e918e8c..a80ba5c 100644 --- a/package.json +++ b/package.json @@ -101,5 +101,8 @@ "typescript": "^5.3.2", "vite": "^5.0.0", "vitest": "^0.34.6" + }, + "dependencies": { + "esbuild": "^0.19.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 271d8dc..bcd60c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + esbuild: + specifier: ^0.19.8 + version: 0.19.8 + devDependencies: '@babel/cli': specifier: ^7.23.4 @@ -771,13 +776,12 @@ packages: dev: true optional: true - /@esbuild/android-arm64@0.19.6: - resolution: {integrity: sha512-KQ/hbe9SJvIJ4sR+2PcZ41IBV+LPJyYp6V1K1P1xcMRup9iYsBoQn4MzE3mhMLOld27Au2eDcLlIREeKGUXpHQ==} + /@esbuild/android-arm64@0.19.8: + resolution: {integrity: sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA==} engines: {node: '>=12'} cpu: [arm64] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/android-arm@0.18.20: @@ -789,13 +793,12 @@ packages: dev: true optional: true - /@esbuild/android-arm@0.19.6: - resolution: {integrity: sha512-muPzBqXJKCbMYoNbb1JpZh/ynl0xS6/+pLjrofcR3Nad82SbsCogYzUE6Aq9QT3cLP0jR/IVK/NHC9b90mSHtg==} + /@esbuild/android-arm@0.19.8: + resolution: {integrity: sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA==} engines: {node: '>=12'} cpu: [arm] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/android-x64@0.18.20: @@ -807,13 +810,12 @@ packages: dev: true optional: true - /@esbuild/android-x64@0.19.6: - resolution: {integrity: sha512-VVJVZQ7p5BBOKoNxd0Ly3xUM78Y4DyOoFKdkdAe2m11jbh0LEU4bPles4e/72EMl4tapko8o915UalN/5zhspg==} + /@esbuild/android-x64@0.19.8: + resolution: {integrity: sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A==} engines: {node: '>=12'} cpu: [x64] os: [android] requiresBuild: true - dev: true optional: true /@esbuild/darwin-arm64@0.18.20: @@ -825,13 +827,12 @@ packages: dev: true optional: true - /@esbuild/darwin-arm64@0.19.6: - resolution: {integrity: sha512-91LoRp/uZAKx6ESNspL3I46ypwzdqyDLXZH7x2QYCLgtnaU08+AXEbabY2yExIz03/am0DivsTtbdxzGejfXpA==} + /@esbuild/darwin-arm64@0.19.8: + resolution: {integrity: sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] requiresBuild: true - dev: true optional: true /@esbuild/darwin-x64@0.18.20: @@ -843,13 +844,12 @@ packages: dev: true optional: true - /@esbuild/darwin-x64@0.19.6: - resolution: {integrity: sha512-QCGHw770ubjBU1J3ZkFJh671MFajGTYMZumPs9E/rqU52md6lIil97BR0CbPq6U+vTh3xnTNDHKRdR8ggHnmxQ==} + /@esbuild/darwin-x64@0.19.8: + resolution: {integrity: sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q==} engines: {node: '>=12'} cpu: [x64] os: [darwin] requiresBuild: true - dev: true optional: true /@esbuild/freebsd-arm64@0.18.20: @@ -861,13 +861,12 @@ packages: dev: true optional: true - /@esbuild/freebsd-arm64@0.19.6: - resolution: {integrity: sha512-J53d0jGsDcLzWk9d9SPmlyF+wzVxjXpOH7jVW5ae7PvrDst4kiAz6sX+E8btz0GB6oH12zC+aHRD945jdjF2Vg==} + /@esbuild/freebsd-arm64@0.19.8: + resolution: {integrity: sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] requiresBuild: true - dev: true optional: true /@esbuild/freebsd-x64@0.18.20: @@ -879,13 +878,12 @@ packages: dev: true optional: true - /@esbuild/freebsd-x64@0.19.6: - resolution: {integrity: sha512-hn9qvkjHSIB5Z9JgCCjED6YYVGCNpqB7dEGavBdG6EjBD8S/UcNUIlGcB35NCkMETkdYwfZSvD9VoDJX6VeUVA==} + /@esbuild/freebsd-x64@0.19.8: + resolution: {integrity: sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] requiresBuild: true - dev: true optional: true /@esbuild/linux-arm64@0.18.20: @@ -897,13 +895,12 @@ packages: dev: true optional: true - /@esbuild/linux-arm64@0.19.6: - resolution: {integrity: sha512-HQCOrk9XlH3KngASLaBfHpcoYEGUt829A9MyxaI8RMkfRA8SakG6YQEITAuwmtzFdEu5GU4eyhKcpv27dFaOBg==} + /@esbuild/linux-arm64@0.19.8: + resolution: {integrity: sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ==} engines: {node: '>=12'} cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-arm@0.18.20: @@ -915,13 +912,12 @@ packages: dev: true optional: true - /@esbuild/linux-arm@0.19.6: - resolution: {integrity: sha512-G8IR5zFgpXad/Zp7gr7ZyTKyqZuThU6z1JjmRyN1vSF8j0bOlGzUwFSMTbctLAdd7QHpeyu0cRiuKrqK1ZTwvQ==} + /@esbuild/linux-arm@0.19.8: + resolution: {integrity: sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ==} engines: {node: '>=12'} cpu: [arm] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-ia32@0.18.20: @@ -933,13 +929,12 @@ packages: dev: true optional: true - /@esbuild/linux-ia32@0.19.6: - resolution: {integrity: sha512-22eOR08zL/OXkmEhxOfshfOGo8P69k8oKHkwkDrUlcB12S/sw/+COM4PhAPT0cAYW/gpqY2uXp3TpjQVJitz7w==} + /@esbuild/linux-ia32@0.19.8: + resolution: {integrity: sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ==} engines: {node: '>=12'} cpu: [ia32] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-loong64@0.18.20: @@ -951,13 +946,12 @@ packages: dev: true optional: true - /@esbuild/linux-loong64@0.19.6: - resolution: {integrity: sha512-82RvaYAh/SUJyjWA8jDpyZCHQjmEggL//sC7F3VKYcBMumQjUL3C5WDl/tJpEiKtt7XrWmgjaLkrk205zfvwTA==} + /@esbuild/linux-loong64@0.19.8: + resolution: {integrity: sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ==} engines: {node: '>=12'} cpu: [loong64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-mips64el@0.18.20: @@ -969,13 +963,12 @@ packages: dev: true optional: true - /@esbuild/linux-mips64el@0.19.6: - resolution: {integrity: sha512-8tvnwyYJpR618vboIv2l8tK2SuK/RqUIGMfMENkeDGo3hsEIrpGldMGYFcWxWeEILe5Fi72zoXLmhZ7PR23oQA==} + /@esbuild/linux-mips64el@0.19.8: + resolution: {integrity: sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-ppc64@0.18.20: @@ -987,13 +980,12 @@ packages: dev: true optional: true - /@esbuild/linux-ppc64@0.19.6: - resolution: {integrity: sha512-Qt+D7xiPajxVNk5tQiEJwhmarNnLPdjXAoA5uWMpbfStZB0+YU6a3CtbWYSy+sgAsnyx4IGZjWsTzBzrvg/fMA==} + /@esbuild/linux-ppc64@0.19.8: + resolution: {integrity: sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-riscv64@0.18.20: @@ -1005,13 +997,12 @@ packages: dev: true optional: true - /@esbuild/linux-riscv64@0.19.6: - resolution: {integrity: sha512-lxRdk0iJ9CWYDH1Wpnnnc640ajF4RmQ+w6oHFZmAIYu577meE9Ka/DCtpOrwr9McMY11ocbp4jirgGgCi7Ls/g==} + /@esbuild/linux-riscv64@0.19.8: + resolution: {integrity: sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-s390x@0.18.20: @@ -1023,13 +1014,12 @@ packages: dev: true optional: true - /@esbuild/linux-s390x@0.19.6: - resolution: {integrity: sha512-MopyYV39vnfuykHanRWHGRcRC3AwU7b0QY4TI8ISLfAGfK+tMkXyFuyT1epw/lM0pflQlS53JoD22yN83DHZgA==} + /@esbuild/linux-s390x@0.19.8: + resolution: {integrity: sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg==} engines: {node: '>=12'} cpu: [s390x] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/linux-x64@0.18.20: @@ -1041,13 +1031,12 @@ packages: dev: true optional: true - /@esbuild/linux-x64@0.19.6: - resolution: {integrity: sha512-UWcieaBzsN8WYbzFF5Jq7QULETPcQvlX7KL4xWGIB54OknXJjBO37sPqk7N82WU13JGWvmDzFBi1weVBajPovg==} + /@esbuild/linux-x64@0.19.8: + resolution: {integrity: sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg==} engines: {node: '>=12'} cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /@esbuild/netbsd-x64@0.18.20: @@ -1059,13 +1048,12 @@ packages: dev: true optional: true - /@esbuild/netbsd-x64@0.19.6: - resolution: {integrity: sha512-EpWiLX0fzvZn1wxtLxZrEW+oQED9Pwpnh+w4Ffv8ZLuMhUoqR9q9rL4+qHW8F4Mg5oQEKxAoT0G+8JYNqCiR6g==} + /@esbuild/netbsd-x64@0.19.8: + resolution: {integrity: sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] requiresBuild: true - dev: true optional: true /@esbuild/openbsd-x64@0.18.20: @@ -1077,13 +1065,12 @@ packages: dev: true optional: true - /@esbuild/openbsd-x64@0.19.6: - resolution: {integrity: sha512-fFqTVEktM1PGs2sLKH4M5mhAVEzGpeZJuasAMRnvDZNCV0Cjvm1Hu35moL2vC0DOrAQjNTvj4zWrol/lwQ8Deg==} + /@esbuild/openbsd-x64@0.19.8: + resolution: {integrity: sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] requiresBuild: true - dev: true optional: true /@esbuild/sunos-x64@0.18.20: @@ -1095,13 +1082,12 @@ packages: dev: true optional: true - /@esbuild/sunos-x64@0.19.6: - resolution: {integrity: sha512-M+XIAnBpaNvaVAhbe3uBXtgWyWynSdlww/JNZws0FlMPSBy+EpatPXNIlKAdtbFVII9OpX91ZfMb17TU3JKTBA==} + /@esbuild/sunos-x64@0.19.8: + resolution: {integrity: sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w==} engines: {node: '>=12'} cpu: [x64] os: [sunos] requiresBuild: true - dev: true optional: true /@esbuild/win32-arm64@0.18.20: @@ -1113,13 +1099,12 @@ packages: dev: true optional: true - /@esbuild/win32-arm64@0.19.6: - resolution: {integrity: sha512-2DchFXn7vp/B6Tc2eKdTsLzE0ygqKkNUhUBCNtMx2Llk4POIVMUq5rUYjdcedFlGLeRe1uLCpVvCmE+G8XYybA==} + /@esbuild/win32-arm64@0.19.8: + resolution: {integrity: sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg==} engines: {node: '>=12'} cpu: [arm64] os: [win32] requiresBuild: true - dev: true optional: true /@esbuild/win32-ia32@0.18.20: @@ -1131,13 +1116,12 @@ packages: dev: true optional: true - /@esbuild/win32-ia32@0.19.6: - resolution: {integrity: sha512-PBo/HPDQllyWdjwAVX+Gl2hH0dfBydL97BAH/grHKC8fubqp02aL4S63otZ25q3sBdINtOBbz1qTZQfXbP4VBg==} + /@esbuild/win32-ia32@0.19.8: + resolution: {integrity: sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw==} engines: {node: '>=12'} cpu: [ia32] os: [win32] requiresBuild: true - dev: true optional: true /@esbuild/win32-x64@0.18.20: @@ -1149,13 +1133,12 @@ packages: dev: true optional: true - /@esbuild/win32-x64@0.19.6: - resolution: {integrity: sha512-OE7yIdbDif2kKfrGa+V0vx/B3FJv2L4KnIiLlvtibPyO9UkgO3rzYE0HhpREo2vmJ1Ixq1zwm9/0er+3VOSZJA==} + /@esbuild/win32-x64@0.19.8: + resolution: {integrity: sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA==} engines: {node: '>=12'} cpu: [x64] os: [win32] requiresBuild: true - dev: true optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.54.0): @@ -2899,35 +2882,34 @@ packages: '@esbuild/win32-x64': 0.18.20 dev: true - /esbuild@0.19.6: - resolution: {integrity: sha512-Xl7dntjA2OEIvpr9j0DVxxnog2fyTGnyVoQXAMQI6eR3mf9zCQds7VIKUDCotDgE/p4ncTgeRqgX8t5d6oP4Gw==} + /esbuild@0.19.8: + resolution: {integrity: sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==} engines: {node: '>=12'} hasBin: true requiresBuild: true optionalDependencies: - '@esbuild/android-arm': 0.19.6 - '@esbuild/android-arm64': 0.19.6 - '@esbuild/android-x64': 0.19.6 - '@esbuild/darwin-arm64': 0.19.6 - '@esbuild/darwin-x64': 0.19.6 - '@esbuild/freebsd-arm64': 0.19.6 - '@esbuild/freebsd-x64': 0.19.6 - '@esbuild/linux-arm': 0.19.6 - '@esbuild/linux-arm64': 0.19.6 - '@esbuild/linux-ia32': 0.19.6 - '@esbuild/linux-loong64': 0.19.6 - '@esbuild/linux-mips64el': 0.19.6 - '@esbuild/linux-ppc64': 0.19.6 - '@esbuild/linux-riscv64': 0.19.6 - '@esbuild/linux-s390x': 0.19.6 - '@esbuild/linux-x64': 0.19.6 - '@esbuild/netbsd-x64': 0.19.6 - '@esbuild/openbsd-x64': 0.19.6 - '@esbuild/sunos-x64': 0.19.6 - '@esbuild/win32-arm64': 0.19.6 - '@esbuild/win32-ia32': 0.19.6 - '@esbuild/win32-x64': 0.19.6 - dev: true + '@esbuild/android-arm': 0.19.8 + '@esbuild/android-arm64': 0.19.8 + '@esbuild/android-x64': 0.19.8 + '@esbuild/darwin-arm64': 0.19.8 + '@esbuild/darwin-x64': 0.19.8 + '@esbuild/freebsd-arm64': 0.19.8 + '@esbuild/freebsd-x64': 0.19.8 + '@esbuild/linux-arm': 0.19.8 + '@esbuild/linux-arm64': 0.19.8 + '@esbuild/linux-ia32': 0.19.8 + '@esbuild/linux-loong64': 0.19.8 + '@esbuild/linux-mips64el': 0.19.8 + '@esbuild/linux-ppc64': 0.19.8 + '@esbuild/linux-riscv64': 0.19.8 + '@esbuild/linux-s390x': 0.19.8 + '@esbuild/linux-x64': 0.19.8 + '@esbuild/netbsd-x64': 0.19.8 + '@esbuild/openbsd-x64': 0.19.8 + '@esbuild/sunos-x64': 0.19.8 + '@esbuild/win32-arm64': 0.19.8 + '@esbuild/win32-ia32': 0.19.8 + '@esbuild/win32-x64': 0.19.8 /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -6103,7 +6085,7 @@ packages: optional: true dependencies: '@types/node': 20.9.2 - esbuild: 0.19.6 + esbuild: 0.19.8 postcss: 8.4.31 rollup: 4.5.0 optionalDependencies: diff --git a/src/CommandDescriptor.ts b/src/CommandDescriptor.ts index 5b57fc7..62288a2 100644 --- a/src/CommandDescriptor.ts +++ b/src/CommandDescriptor.ts @@ -2,7 +2,7 @@ * @since 1.0.0 */ import type { FileSystem } from "@effect/platform/FileSystem" -import type { Terminal } from "@effect/platform/Terminal" +import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" @@ -276,12 +276,14 @@ export const withSubcommands: { */ export const wizard: { ( + rootCommand: string, config: CliConfig ): ( self: Command - ) => Effect> + ) => Effect> ( self: Command, + rootCommand: string, config: CliConfig - ): Effect> + ): Effect> } = Internal.wizard diff --git a/src/Prompt.ts b/src/Prompt.ts index a3333ad..6d8e1ca 100644 --- a/src/Prompt.ts +++ b/src/Prompt.ts @@ -1,7 +1,7 @@ /** * @since 1.0.0 */ -import type { Terminal, UserInput } from "@effect/platform/Terminal" +import type { QuitException, Terminal, UserInput } from "@effect/platform/Terminal" import type { Effect } from "effect/Effect" import type { Option } from "effect/Option" import type { Pipeable } from "effect/Pipeable" @@ -32,7 +32,7 @@ export type PromptTypeId = typeof PromptTypeId * @category models */ export interface Prompt - extends Prompt.Variance, Pipeable, Effect + extends Prompt.Variance, Pipeable, Effect {} /** @@ -455,7 +455,7 @@ export const map: { * @since 1.0.0 * @category execution */ -export const run: (self: Prompt) => Effect = +export const run: (self: Prompt) => Effect = InternalPrompt.run /** diff --git a/src/internal/args.ts b/src/internal/args.ts index ee0fba5..79269ed 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -343,12 +343,12 @@ export const withDescription = dual< export const wizard = dual< (config: CliConfig.CliConfig) => (self: Args.Args) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray >, (self: Args.Args, config: CliConfig.CliConfig) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > >(2, (self, config) => wizardInternal(self as Instruction, config)) @@ -734,11 +734,9 @@ const withDescriptionInternal = (self: Instruction, description: string): Args.A } } -const wizardHeader = InternalHelpDoc.p("ARG WIZARD") - const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > => { switch (self._tag) { @@ -746,14 +744,13 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. return Effect.succeed(ReadonlyArray.empty()) } case "Single": { - const help = InternalHelpDoc.sequence(wizardHeader, getHelpInternal(self)) - return Console.log().pipe( - Effect.zipRight( - InternalPrimitive.wizard(self.primitiveType, help).pipe(Effect.flatMap((input) => { - const args = ReadonlyArray.of(input as string) - return validateInternal(self, args, config).pipe(Effect.as(args)) - })) - ) + const help = getHelpInternal(self) + return InternalPrimitive.wizard(self.primitiveType, help).pipe( + Effect.zipLeft(Console.log()), + Effect.flatMap((input) => { + const args = ReadonlyArray.of(input as string) + return validateInternal(self, args, config).pipe(Effect.as(args)) + }) ) } case "Map": { @@ -773,16 +770,15 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. "How many times should this argument should be repeated?" ) const message = pipe( - wizardHeader, - InternalHelpDoc.sequence(getHelpInternal(self)), + getHelpInternal(self), InternalHelpDoc.sequence(repeatHelp) ) - return Console.log().pipe( - Effect.zipRight(InternalNumberPrompt.integer({ - message: InternalHelpDoc.toAnsiText(message).trimEnd(), - min: getMinSizeInternal(self), - max: getMaxSizeInternal(self) - })), + return InternalNumberPrompt.integer({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + min: getMinSizeInternal(self), + max: getMaxSizeInternal(self) + }).pipe( + Effect.zipLeft(Console.log()), Effect.flatMap((n) => Ref.make(ReadonlyArray.empty()).pipe( Effect.flatMap((ref) => @@ -800,20 +796,17 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. case "WithDefault": { const defaultHelp = InternalHelpDoc.p(`This argument is optional - use the default?`) const message = pipe( - wizardHeader, - InternalHelpDoc.sequence(getHelpInternal(self.args as Instruction)), + getHelpInternal(self.args as Instruction), 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 } - ] - }) - ), + return InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices: [ + { title: `Default ['${JSON.stringify(self.fallback)}']`, value: true }, + { title: "Custom", value: false } + ] + }).pipe( + Effect.zipLeft(Console.log()), Effect.flatMap((useFallback) => useFallback ? Effect.succeed(ReadonlyArray.empty()) diff --git a/src/internal/cliApp.ts b/src/internal/cliApp.ts index d3699ca..8247f30 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -185,42 +185,53 @@ const handleBuiltInOption = ( ) } case "ShowWizard": { - const summary = InternalSpan.isEmpty(self.summary) - ? InternalSpan.empty - : InternalSpan.spans([ - InternalSpan.space, - InternalSpan.text("--"), - InternalSpan.space, - self.summary - ]) - const instructions = InternalHelpDoc.sequence( - InternalHelpDoc.p(InternalSpan.spans([ - InternalSpan.text("The wizard mode will assist you with constructing commands for"), - InternalSpan.space, - InternalSpan.code(`${self.name} (${self.version})`), - InternalSpan.text(".") - ])), - InternalHelpDoc.p("Please answer all prompts provided by the wizard.") - ) - const description = InternalHelpDoc.descriptionList([[ - InternalSpan.text("Instructions"), - instructions - ]]) - const header = InternalHelpDoc.h1( - InternalSpan.spans([ - InternalSpan.code("Wizard Mode for CLI Application:"), - InternalSpan.space, - InternalSpan.code(self.name), - InternalSpan.space, - InternalSpan.code(`(${self.version})`), - summary - ]) - ) - const help = InternalHelpDoc.sequence(header, description) - 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)))) + const commandNames = ReadonlyArray.fromIterable(InternalCommand.getNames(self.command)) + if (ReadonlyArray.isNonEmptyReadonlyArray(commandNames)) { + const programName = ReadonlyArray.headNonEmpty(commandNames) + const summary = InternalSpan.isEmpty(self.summary) + ? InternalSpan.empty + : InternalSpan.spans([ + InternalSpan.space, + InternalSpan.text("--"), + InternalSpan.space, + self.summary + ]) + const instructions = InternalHelpDoc.sequence( + InternalHelpDoc.p(InternalSpan.spans([ + InternalSpan.text("The wizard mode will assist you with constructing commands for"), + InternalSpan.space, + InternalSpan.code(`${self.name} (${self.version})`), + InternalSpan.text(".") + ])), + InternalHelpDoc.p("Please answer all prompts provided by the wizard.") + ) + const description = InternalHelpDoc.descriptionList([[ + InternalSpan.text("Instructions"), + instructions + ]]) + const header = InternalHelpDoc.h1( + InternalSpan.spans([ + InternalSpan.code("Wizard Mode for CLI Application:"), + InternalSpan.space, + InternalSpan.code(self.name), + InternalSpan.space, + InternalSpan.code(`(${self.version})`), + summary + ]) + ) + const help = InternalHelpDoc.sequence(header, description) + const text = InternalHelpDoc.toAnsiText(help) + return Console.log(text).pipe( + Effect.zipRight(InternalCommand.wizard(builtIn.command, programName, config)), + Effect.tap((args) => Console.log(InternalHelpDoc.toAnsiText(renderWizardArgs(args)))), + Effect.catchTag("QuitException", () => { + const message = InternalHelpDoc.p(InternalSpan.error("\n\nQuitting wizard mode...")) + return Console.log(InternalHelpDoc.toAnsiText(message)) + }) + ) + } + throw new Error( + "[BUG]: BuiltInOptions.showWizard - received empty list of command names" ) } case "ShowVersion": { @@ -268,11 +279,10 @@ const renderWizardArgs = (args: ReadonlyArray) => { ReadonlyArray.filter(args, (param) => param.length > 0), ReadonlyArray.join(" ") ) - const executeMsg = InternalSpan.weak( + const executeMsg = InternalSpan.text( "You may now execute your command directly with the following options and arguments:" ) return InternalHelpDoc.blocks([ - InternalHelpDoc.p(""), InternalHelpDoc.p(InternalSpan.strong(InternalSpan.code("Wizard Mode Complete!"))), InternalHelpDoc.p(executeMsg), InternalHelpDoc.p(InternalSpan.code(` ${params}`)) diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index 37c035d..3b28e3f 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -10,6 +10,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 * as SynchronizedRef from "effect/SynchronizedRef" import type * as Args from "../Args.js" import type * as CliConfig from "../CliConfig.js" @@ -340,17 +341,24 @@ export const withSubcommands = dual< /** @internal */ export const wizard = dual< - (config: CliConfig.CliConfig) => (self: Descriptor.Command) => Effect.Effect< + ( + rootCommand: string, + config: CliConfig.CliConfig + ) => (self: Descriptor.Command) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray >, - (self: Descriptor.Command, config: CliConfig.CliConfig) => Effect.Effect< + ( + self: Descriptor.Command, + rootCommand: string, + config: CliConfig.CliConfig + ) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > ->(2, (self, config) => wizardInternal(self as Instruction, config)) +>(3, (self, rootCommand, config) => wizardInternal(self as Instruction, rootCommand, config)) // ============================================================================= // Internals @@ -672,6 +680,7 @@ const parseInternal = ( } case "GetUserInput": { return InternalPrompt.run(self.prompt).pipe( + Effect.catchTag("QuitException", (e) => Effect.die(e)), Effect.map((value) => InternalCommandDirective.userDefined(ReadonlyArray.drop(args, 1), { name: self.name, @@ -867,44 +876,70 @@ const withDescriptionInternal = ( } } +const argsWizardHeader = InternalSpan.code("Args Wizard - ") +const optionsWizardHeader = InternalSpan.code("Options Wizard - ") + const wizardInternal = ( self: Instruction, + rootCommand: string, config: CliConfig.CliConfig ): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > => { - const loop = (self: WizardCommandSequence): Effect.Effect< + const loop = ( + self: WizardCommandSequence, + commandLineRef: Ref.Ref> + ): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | 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) - ))) - ) + return Effect.gen(function*(_) { + const logCurrentCommand = Ref.get(commandLineRef).pipe(Effect.flatMap((commandLine) => { + const currentCommand = InternalHelpDoc.p(pipe( + InternalSpan.strong(InternalSpan.code("COMMAND:")), + InternalSpan.concat(InternalSpan.space), + InternalSpan.concat(InternalSpan.code(ReadonlyArray.join(commandLine, " "))) + )) + return Console.log(InternalHelpDoc.toAnsiText(currentCommand)) + })) + if (isStandard(self.command)) { + // Log the current command line arguments + yield* _(logCurrentCommand) + // If the command has options, run the wizard for them + if (!InternalOptions.isEmpty(self.command.options as InternalOptions.Instruction)) { + const commandName = InternalSpan.code(self.command.name) + const message = InternalHelpDoc.p( + InternalSpan.concat(optionsWizardHeader, commandName) + ) + yield* _(Console.log(InternalHelpDoc.toAnsiText(message))) + const options = yield* _(InternalOptions.wizard(self.command.options, config)) + yield* _(Ref.updateAndGet(commandLineRef, ReadonlyArray.appendAll(options))) + yield* _(logCurrentCommand) + } + if (!InternalArgs.isEmpty(self.command.args as InternalArgs.Instruction)) { + const commandName = InternalSpan.code(self.command.name) + const message = InternalHelpDoc.p( + InternalSpan.concat(argsWizardHeader, commandName) + ) + yield* _(Console.log(InternalHelpDoc.toAnsiText(message))) + const options = yield* _(InternalArgs.wizard(self.command.args, config)) + yield* _(Ref.updateAndGet(commandLineRef, ReadonlyArray.appendAll(options))) + yield* _(logCurrentCommand) + } + } + return yield* _(Ref.get(commandLineRef)) + }) } case "AlternativeCommandWizard": { - const makeChoice = (title: string, value: WizardCommandSequence) => ({ title, value }) + const makeChoice = (title: string, value: WizardCommandSequence) => ({ + title, + value: [title, value] as const + }) const choices = self.alternatives.map((alternative) => { switch (alternative._tag) { case "SingleCommandWizard": { @@ -918,19 +953,25 @@ const wizardInternal = ( 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)) + Effect.tap(([name]) => Ref.update(commandLineRef, ReadonlyArray.append(name))), + Effect.zipLeft(Console.log()), + Effect.flatMap(([, nextSequence]) => loop(nextSequence, commandLineRef)) ) } case "SubcommandWizard": { - return Effect.zipWith( - loop(self.parent), - loop(self.child), - (parent, child) => ReadonlyArray.appendAll(parent, child) + return loop(self.parent, commandLineRef).pipe( + Effect.zipRight(loop(self.child, commandLineRef)) ) } } } - return loop(getWizardCommandSequence(self)) + return Ref.make>(ReadonlyArray.of(rootCommand)).pipe( + Effect.flatMap((commandLineRef) => + loop(getWizardCommandSequence(self), commandLineRef).pipe( + Effect.zipRight(Ref.get(commandLineRef)) + ) + ) + ) } type WizardCommandSequence = SingleCommandWizard | AlternativeCommandWizard | SubcommandWizard diff --git a/src/internal/options.ts b/src/internal/options.ts index 2ac6348..33229ce 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -553,12 +553,12 @@ export const withPseudoName = dual< export const wizard = dual< (config: CliConfig.CliConfig) => (self: Options.Options) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray >, (self: Options.Options, config: CliConfig.CliConfig) => Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > >(2, (self, config) => wizardInternal(self as Instruction, config)) @@ -1353,11 +1353,9 @@ const parseInternal = ( } } -const wizardHeader = InternalHelpDoc.p("Option Wizard") - const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > => { switch (self._tag) { @@ -1365,31 +1363,29 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. return Effect.succeed(ReadonlyArray.empty()) } case "Single": { - const help = InternalHelpDoc.sequence(wizardHeader, getHelpInternal(self)) - return Console.log().pipe( - Effect.zipRight( - InternalPrimitive.wizard(self.primitiveType, help).pipe(Effect.flatMap((input) => { - // There will always be at least one name in names - const args = ReadonlyArray.make(getNames(self)[0]!, input as string) - return parseOptions(self, args, config).pipe(Effect.as(args)) - })) - ) + const help = getHelpInternal(self) + return InternalPrimitive.wizard(self.primitiveType, help).pipe( + Effect.flatMap((input) => { + // There will always be at least one name in names + const args = ReadonlyArray.make(getNames(self)[0]!, input as string) + return parseOptions(self, args, config).pipe(Effect.as(args)) + }), + Effect.zipLeft(Console.log()) ) } case "KeyValueMap": { - const optionHelp = InternalHelpDoc.p("Enter `key=value` pairs separated by spaces") - const message = InternalHelpDoc.sequence(wizardHeader, optionHelp) - return Console.log().pipe( - Effect.zipRight(InternalListPrompt.list({ - message: InternalHelpDoc.toAnsiText(message).trim(), - delimiter: " " - })), + const message = InternalHelpDoc.p("Enter `key=value` pairs separated by spaces") + return InternalListPrompt.list({ + message: InternalHelpDoc.toAnsiText(message).trim(), + delimiter: " " + }).pipe( Effect.flatMap((args) => { const identifier = Option.getOrElse(getIdentifierInternal(self), () => "") return parseInternal(self, HashMap.make([identifier, args]), config).pipe( Effect.as(ReadonlyArray.prepend(args, identifier)) ) - }) + }), + Effect.zipLeft(Console.log()) ) } case "Map": { @@ -1405,8 +1401,7 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. case "OrElse": { const alternativeHelp = InternalHelpDoc.p("Select which option you would like to use") const message = pipe( - wizardHeader, - InternalHelpDoc.sequence(getHelpInternal(self)), + getHelpInternal(self), InternalHelpDoc.sequence(alternativeHelp) ) const makeChoice = (title: string, value: Instruction) => ({ title, value }) @@ -1420,29 +1415,24 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. (title) => makeChoice(title, self.right as Instruction) ) ]) - return Console.log().pipe( - Effect.zipRight(InternalSelectPrompt.select({ - message: InternalHelpDoc.toAnsiText(message).trimEnd(), - choices - })), - Effect.flatMap((option) => wizardInternal(option, config)) - ) + return InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices + }).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)), + getHelpInternal(self), InternalHelpDoc.sequence(repeatHelp) ) - return Console.log().pipe( - Effect.zipRight(InternalNumberPrompt.integer({ - message: InternalHelpDoc.toAnsiText(message).trimEnd(), - min: getMinSizeInternal(self), - max: getMaxSizeInternal(self) - })), + return InternalNumberPrompt.integer({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + min: getMinSizeInternal(self), + max: getMaxSizeInternal(self) + }).pipe( Effect.flatMap((n) => Ref.make(ReadonlyArray.empty()).pipe( Effect.flatMap((ref) => @@ -1462,18 +1452,16 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. } const defaultHelp = InternalHelpDoc.p(`This option is optional - use the default?`) const message = pipe( - wizardHeader, - InternalHelpDoc.sequence(getHelpInternal(self.options as Instruction)), + getHelpInternal(self.options as Instruction), 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 } - ] - })), + return InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices: [ + { title: `Default ['${JSON.stringify(self.fallback)}']`, value: true }, + { title: "Custom", value: false } + ] + }).pipe( Effect.flatMap((useFallback) => useFallback ? Effect.succeed(ReadonlyArray.empty()) diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 987cc44..4016a4d 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -510,7 +510,7 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt initial, active: "true", inactive: "false" - }).pipe(InternalPrompt.map((bool) => bool)) + }).pipe(InternalPrompt.map((bool) => `${bool}`)) } case "Choice": { const primitiveHelp = InternalHelpDoc.p("Select one of the following choices") diff --git a/src/internal/prompt.ts b/src/internal/prompt.ts index 7c862b1..0ad1c85 100644 --- a/src/internal/prompt.ts +++ b/src/internal/prompt.ts @@ -24,7 +24,7 @@ const proto = { [PromptTypeId]: { _Output: (_: never) => _ }, - commit(): Effect.Effect { + commit(): Effect.Effect { return run(this as Prompt.Prompt) }, pipe() { @@ -164,7 +164,7 @@ export const flatMap = dual< /** @internal */ export const run = ( self: Prompt.Prompt -): Effect.Effect => +): Effect.Effect => Effect.flatMap(Terminal.Terminal, (terminal) => { const op = self as Primitive switch (op._tag) { @@ -176,13 +176,12 @@ export const run = ( Effect.flatMap(([prevStateRef, nextStateRef]) => { const loop = ( action: Exclude, { _tag: "Submit" }> - ): Effect.Effect => + ): Effect.Effect => Effect.all([Ref.get(prevStateRef), Ref.get(nextStateRef)]).pipe( Effect.flatMap(([prevState, nextState]) => op.render(prevState, nextState, action).pipe( Effect.flatMap((msg) => Effect.orDie(terminal.display(msg))), Effect.zipRight(terminal.readInput), - Effect.catchTag("QuitException", (e) => Effect.die(e)), Effect.flatMap((input) => op.process(input, nextState)), Effect.flatMap((action) => { switch (action._tag) { From 273aa84bfe813b963f1de2b4c540bd5cfb2b5a81 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 11:10:52 -0500 Subject: [PATCH 23/39] more wizard improvements --- src/HelpDoc/Span.ts | 51 +++++++++++------------------- src/internal/commandDescriptor.ts | 6 +++- src/internal/helpDoc/span.ts | 52 +++++++++++++------------------ src/internal/options.ts | 1 + src/internal/primitive.ts | 4 ++- 5 files changed, 49 insertions(+), 65 deletions(-) diff --git a/src/HelpDoc/Span.ts b/src/HelpDoc/Span.ts index 99b014a..3f2812d 100644 --- a/src/HelpDoc/Span.ts +++ b/src/HelpDoc/Span.ts @@ -1,47 +1,41 @@ /** * @since 1.0.0 */ +import type { Color } from "@effect/printer-ansi/Color" import * as InternalSpan from "../internal/helpDoc/span.js" /** * @since 1.0.0 * @category models */ -export type Span = Text | Code | Error | Weak | Strong | URI | Sequence +export type Span = Highlight | Sequence | Strong | Text | URI | Weak /** * @since 1.0.0 * @category models */ -export interface Text { - readonly _tag: "Text" - readonly value: string -} - -/** - * @since 1.0.0 - * @category models - */ -export interface Code { - readonly _tag: "Code" - readonly value: string +export interface Highlight { + readonly _tag: "Highlight" + readonly value: Span + readonly color: Color } /** * @since 1.0.0 * @category models */ -export interface Error { - readonly _tag: "Error" - readonly value: Span +export interface Sequence { + readonly _tag: "Sequence" + readonly left: Span + readonly right: Span } /** * @since 1.0.0 * @category models */ -export interface Weak { - readonly _tag: "Weak" +export interface Strong { + readonly _tag: "Strong" readonly value: Span } @@ -49,9 +43,9 @@ export interface Weak { * @since 1.0.0 * @category models */ -export interface Strong { - readonly _tag: "Strong" - readonly value: Span +export interface Text { + readonly _tag: "Text" + readonly value: string } /** @@ -67,18 +61,11 @@ export interface URI { * @since 1.0.0 * @category models */ -export interface Sequence { - readonly _tag: "Sequence" - readonly left: Span - readonly right: Span +export interface Weak { + readonly _tag: "Weak" + readonly value: Span } -/** - * @since 1.0.0 - * @category refinements - */ -export const isError: (self: Span) => self is Error = InternalSpan.isError - /** * @since 1.0.0 * @category refinements @@ -131,7 +118,7 @@ export const text: (value: string) => Span = InternalSpan.text * @since 1.0.0 * @category constructors */ -export const code: (value: string) => Span = InternalSpan.code +export const code: (value: string | Span) => Span = InternalSpan.code /** * @since 1.0.0 diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index 3b28e3f..8164814 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -1,5 +1,6 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Terminal from "@effect/platform/Terminal" +import * as Color from "@effect/printer-ansi/Color" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -903,7 +904,10 @@ const wizardInternal = ( const currentCommand = InternalHelpDoc.p(pipe( InternalSpan.strong(InternalSpan.code("COMMAND:")), InternalSpan.concat(InternalSpan.space), - InternalSpan.concat(InternalSpan.code(ReadonlyArray.join(commandLine, " "))) + InternalSpan.concat(InternalSpan.highlight( + ReadonlyArray.join(commandLine, " "), + Color.cyan + )) )) return Console.log(InternalHelpDoc.toAnsiText(currentCommand)) })) diff --git a/src/internal/helpDoc/span.ts b/src/internal/helpDoc/span.ts index dfed7b4..66dcc3f 100644 --- a/src/internal/helpDoc/span.ts +++ b/src/internal/helpDoc/span.ts @@ -19,21 +19,16 @@ export const empty: Span.Span = text("") export const space: Span.Span = text(" ") /** @internal */ -export const code = (value: string): Span.Span => ({ - _tag: "Code", - value -}) +export const code = (value: Span.Span | string): Span.Span => highlight(value, Color.white) /** @internal */ -export const error = (value: Span.Span | string): Span.Span => ({ - _tag: "Error", - value: typeof value === "string" ? text(value) : value -}) +export const error = (value: Span.Span | string): Span.Span => highlight(value, Color.red) /** @internal */ -export const weak = (value: Span.Span | string): Span.Span => ({ - _tag: "Weak", - value: typeof value === "string" ? text(value) : value +export const highlight = (value: Span.Span | string, color: Color.Color): Span.Span => ({ + _tag: "Highlight", + value: typeof value === "string" ? text(value) : value, + color }) /** @internal */ @@ -49,10 +44,10 @@ export const uri = (value: string): Span.Span => ({ }) /** @internal */ -export const isCode = (self: Span.Span): self is Span.Code => self._tag === "Code" - -/** @internal */ -export const isError = (self: Span.Span): self is Span.Error => self._tag === "Error" +export const weak = (value: Span.Span | string): Span.Span => ({ + _tag: "Weak", + value: typeof value === "string" ? text(value) : value +}) /** @internal */ export const isSequence = (self: Span.Span): self is Span.Sequence => self._tag === "Sequence" @@ -82,11 +77,10 @@ export const concat = dual< export const getText = (self: Span.Span): string => { switch (self._tag) { case "Text": - case "Code": case "URI": { return self.value } - case "Error": + case "Highlight": case "Weak": case "Strong": { return getText(self.value) @@ -112,12 +106,11 @@ export const isEmpty = (self: Span.Span): boolean => size(self) === 0 /** @internal */ export const size = (self: Span.Span): number => { switch (self._tag) { - case "Code": case "Text": case "URI": { return self.value.length } - case "Error": + case "Highlight": case "Strong": case "Weak": { return size(self.value) @@ -131,26 +124,23 @@ export const size = (self: Span.Span): number => { /** @internal */ export const toAnsiDoc = (self: Span.Span): AnsiDoc.AnsiDoc => { switch (self._tag) { - case "Text": { - return Doc.text(self.value) - } - case "Code": { - return Doc.annotate(Doc.text(self.value), AnsiStyle.color(Color.white)) + case "Highlight": { + return Doc.annotate(toAnsiDoc(self.value), AnsiStyle.color(self.color)) } - case "Error": { - return Doc.annotate(toAnsiDoc(self.value), AnsiStyle.color(Color.red)) - } - case "Weak": { - return Doc.annotate(toAnsiDoc(self.value), AnsiStyle.dullColor(Color.black)) + case "Sequence": { + return Doc.cat(toAnsiDoc(self.left), toAnsiDoc(self.right)) } case "Strong": { return Doc.annotate(toAnsiDoc(self.value), AnsiStyle.bold) } + case "Text": { + return Doc.text(self.value) + } case "URI": { return Doc.annotate(Doc.text(self.value), AnsiStyle.underlined) } - case "Sequence": { - return Doc.cat(toAnsiDoc(self.left), toAnsiDoc(self.right)) + case "Weak": { + return Doc.annotate(toAnsiDoc(self.value), AnsiStyle.dullColor(Color.black)) } } } diff --git a/src/internal/options.ts b/src/internal/options.ts index 33229ce..855ca18 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1462,6 +1462,7 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. { title: "Custom", value: false } ] }).pipe( + Effect.zipLeft(Console.log()), Effect.flatMap((useFallback) => useFallback ? Effect.succeed(ReadonlyArray.empty()) diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 4016a4d..7f8bddc 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -554,7 +554,9 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt case "Text": { const primitiveHelp = InternalHelpDoc.p("Enter some text") const message = InternalHelpDoc.sequence(help, primitiveHelp) - return InternalTextPrompt.text({ message: InternalHelpDoc.toAnsiText(message).trimEnd() }) + return InternalTextPrompt.text({ + message: InternalHelpDoc.toAnsiText(message).trimEnd() + }) } } } From 47a0cb56934dcd270c7408ed6196139e4af99555 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 11:57:20 -0500 Subject: [PATCH 24/39] add colors for accent --- src/internal/cliApp.ts | 6 +++++- src/internal/commandDescriptor.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/internal/cliApp.ts b/src/internal/cliApp.ts index 8247f30..fe96250 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -1,4 +1,5 @@ import type * as Terminal from "@effect/platform/Terminal" +import * as Color from "@effect/printer-ansi/Color" import * as Console from "effect/Console" import * as Context from "effect/Context" import * as Effect from "effect/Effect" @@ -285,6 +286,9 @@ const renderWizardArgs = (args: ReadonlyArray) => { return InternalHelpDoc.blocks([ InternalHelpDoc.p(InternalSpan.strong(InternalSpan.code("Wizard Mode Complete!"))), InternalHelpDoc.p(executeMsg), - InternalHelpDoc.p(InternalSpan.code(` ${params}`)) + InternalHelpDoc.p(InternalSpan.concat( + InternalSpan.text(" "), + InternalSpan.highlight(params, Color.cyan) + )) ]) } diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index 8164814..76b472a 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -902,11 +902,11 @@ const wizardInternal = ( return Effect.gen(function*(_) { const logCurrentCommand = Ref.get(commandLineRef).pipe(Effect.flatMap((commandLine) => { const currentCommand = InternalHelpDoc.p(pipe( - InternalSpan.strong(InternalSpan.code("COMMAND:")), + InternalSpan.strong(InternalSpan.highlight("COMMAND:", Color.cyan)), InternalSpan.concat(InternalSpan.space), InternalSpan.concat(InternalSpan.highlight( ReadonlyArray.join(commandLine, " "), - Color.cyan + Color.magenta )) )) return Console.log(InternalHelpDoc.toAnsiText(currentCommand)) @@ -916,7 +916,7 @@ const wizardInternal = ( yield* _(logCurrentCommand) // If the command has options, run the wizard for them if (!InternalOptions.isEmpty(self.command.options as InternalOptions.Instruction)) { - const commandName = InternalSpan.code(self.command.name) + const commandName = InternalSpan.highlight(self.command.name, Color.magenta) const message = InternalHelpDoc.p( InternalSpan.concat(optionsWizardHeader, commandName) ) From 525213477f66d4685546c6dcf10ca88853eb83bf Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 08:59:27 +1300 Subject: [PATCH 25/39] fix wizard types --- src/Args.ts | 8 +++++--- src/Options.ts | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Args.ts b/src/Args.ts index 07edf4f..b88cb97 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -2,7 +2,7 @@ * @since 1.0.0 */ import type { FileSystem } from "@effect/platform/FileSystem" -import type { Terminal } from "@effect/platform/Terminal" +import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { Option } from "effect/Option" @@ -369,9 +369,11 @@ export const withDescription: { export const wizard: { ( config: CliConfig - ): (self: Args) => Effect> + ): ( + self: Args + ) => Effect> ( self: Args, config: CliConfig - ): Effect> + ): Effect> } = InternalArgs.wizard diff --git a/src/Options.ts b/src/Options.ts index c19a4ca..1104189 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -2,7 +2,7 @@ * @since 1.0.0 */ import type { FileSystem } from "@effect/platform/FileSystem" -import type { Terminal } from "@effect/platform/Terminal" +import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" @@ -474,9 +474,11 @@ export const withPseudoName: { export const wizard: { ( config: CliConfig - ): (self: Options) => Effect> + ): ( + self: Options + ) => Effect> ( self: Options, config: CliConfig - ): Effect> + ): Effect> } = InternalOptions.wizard From 1fb5d09dd3fe9856a713ec709f167950f1178ab6 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 15:09:41 -0500 Subject: [PATCH 26/39] add description to args --- examples/naval-fate.ts | 6 +++--- src/internal/commandDescriptor.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index eb7c33a..4aa173b 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -18,9 +18,9 @@ const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunct // 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 nameArg = Args.text({ name: "name" }).pipe(Args.withDescription("The name of the ship")) +const xArg = Args.integer({ name: "x" }).pipe(Args.withDescription("The x coordinate")) +const yArg = Args.integer({ name: "y" }).pipe(Args.withDescription("The y coordinate")) const coordinatesArg = { x: xArg, y: yArg } const nameAndCoordinatesArg = { name: nameArg, ...coordinatesArg } diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index 76b472a..d9da44d 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -914,9 +914,9 @@ const wizardInternal = ( if (isStandard(self.command)) { // Log the current command line arguments yield* _(logCurrentCommand) + const commandName = InternalSpan.highlight(self.command.name, Color.magenta) // If the command has options, run the wizard for them if (!InternalOptions.isEmpty(self.command.options as InternalOptions.Instruction)) { - const commandName = InternalSpan.highlight(self.command.name, Color.magenta) const message = InternalHelpDoc.p( InternalSpan.concat(optionsWizardHeader, commandName) ) @@ -925,8 +925,8 @@ const wizardInternal = ( yield* _(Ref.updateAndGet(commandLineRef, ReadonlyArray.appendAll(options))) yield* _(logCurrentCommand) } + // If the command has args, run the wizard for them if (!InternalArgs.isEmpty(self.command.args as InternalArgs.Instruction)) { - const commandName = InternalSpan.code(self.command.name) const message = InternalHelpDoc.p( InternalSpan.concat(argsWizardHeader, commandName) ) From 543b1557304698620b89ef1987f47f1d084437d9 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:18:28 +1300 Subject: [PATCH 27/39] consolidate Command.make --- examples/naval-fate.ts | 6 +-- src/Command.ts | 57 +++++++++++++-------------- src/internal/command.ts | 86 ++++++++++++++++++++++++----------------- 3 files changed, 83 insertions(+), 66 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index eb7c33a..c26eaf1 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,7 +32,7 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const shipCommandParent = Command.makeUnit("ship", { +const shipCommandParent = Command.make("ship", { verbose: Options.boolean("verbose") }) @@ -73,7 +73,7 @@ const shipCommand = Command.withSubcommands(shipCommandParent, [ shootShipCommand ]) -const mineCommandParent = Command.makeUnit("mine") +const mineCommandParent = Command.make("mine") const setMineCommand = Command.make("set", { ...coordinatesArg, @@ -99,7 +99,7 @@ const mineCommand = Command.withSubcommands(mineCommandParent, [ removeMineCommand ]) -const run = Command.makeUnit("naval_fate").pipe( +const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), Command.withSubcommands([shipCommand, mineCommand]), Command.run({ diff --git a/src/Command.ts b/src/Command.ts index 622c643..fb24b18 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -116,9 +116,18 @@ export declare namespace Command { * @category constructors */ export const fromDescriptor: { + (): ( + command: Descriptor.Command + ) => Command + ( handler: (_: A) => Effect ): (command: Descriptor.Command) => Command + + ( + descriptor: Descriptor.Command + ): Command + ( descriptor: Descriptor.Command, handler: (_: A) => Effect @@ -129,31 +138,14 @@ export const fromDescriptor: { * @since 1.0.0 * @category constructors */ -export const fromDescriptorUnit: ( - descriptor: Descriptor.Command -) => Command = Internal.fromDescriptorUnit - -/** - * @since 1.0.0 - * @category constructors - */ -export const make: ( - name: Name, - config: Config, - handler: (_: Types.Simplify>) => Effect -) => Command< - Name, - R, - E, - Types.Simplify> -> = Internal.make +export const make: { + (name: Name): Command< + Name, + never, + never, + {} + > -/** - * @since 1.0.0 - * @category constructors - */ -export const makeUnit: { - (name: Name): Command ( name: Name, config: Config @@ -161,11 +153,20 @@ export const makeUnit: { Name, never, never, - Types.Simplify< - Types.Simplify<{ readonly [Key in keyof Config]: Command.ParseConfigValue }> - > + Types.Simplify> + > + + ( + name: Name, + config: Config, + handler: (_: Types.Simplify>) => Effect + ): Command< + Name, + R, + E, + Types.Simplify> > -} = Internal.makeUnit +} = Internal.make /** * @since 1.0.0 diff --git a/src/internal/command.ts b/src/internal/command.ts index 1c5a00a..7e16b48 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -141,25 +141,34 @@ const makeProto = ( /** @internal */ export const fromDescriptor = dual< - ( - handler: (_: A) => Effect.Effect - ) => (command: Descriptor.Command) => Command.Command, - ( - descriptor: Descriptor.Command, - handler: (_: A) => Effect.Effect - ) => Command.Command ->(2, (descriptor, handler) => makeProto(descriptor, handler)) - -/** @internal */ -export const fromDescriptorUnit = ( - descriptor: Descriptor.Command -): Command.Command => { - const self: Command.Command = makeProto( - descriptor, - (_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(self))) - ) - return self as any -} + { + (): ( + command: Descriptor.Command + ) => Command.Command + ( + handler: (_: A) => Effect.Effect + ): (command: Descriptor.Command) => Command.Command + }, + { + ( + descriptor: Descriptor.Command + ): Command.Command + ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect.Effect + ): Command.Command + } +>( + (args) => InternalDescriptor.isCommand(args[0]), + (descriptor: Descriptor.Command, handler?: (_: any) => Effect.Effect) => { + const command: Command.Command = makeProto( + descriptor, + handler ?? + ((_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(command)))) + ) + return self as any + } +) const makeDescriptor = ( name: string, @@ -173,22 +182,14 @@ const makeDescriptor = ( } /** @internal */ -export const make = ( - name: Name, - config: Config, - handler: (_: Types.Simplify>) => Effect.Effect -): Command.Command< - Name, - R, - E, - Types.Simplify> -> => makeProto(makeDescriptor(name, config), handler) +export const make: { + (name: Name): Command.Command< + Name, + never, + never, + {} + > -/** @internal */ -export const makeUnit: { - ( - name: Name - ): Command.Command ( name: Name, config: Config @@ -198,7 +199,22 @@ export const makeUnit: { never, Types.Simplify> > -} = (name: string, config = {}) => fromDescriptorUnit(makeDescriptor(name, config) as any) as any + + ( + name: Name, + config: Config, + handler: (_: Types.Simplify>) => Effect.Effect + ): Command.Command< + Name, + R, + E, + Types.Simplify> + > +} = ( + name: string, + config: Command.Command.ConfigBase = {}, + handler?: (_: any) => Effect.Effect +) => fromDescriptor(makeDescriptor(name, config) as any, handler as any) as any /** @internal */ export const mapDescriptor = dual< From 59813c7c22061d1bb36f534175d68ed87266fecf Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:28:23 +1300 Subject: [PATCH 28/39] remove orElse apis --- src/CommandDescriptor.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/CommandDescriptor.ts b/src/CommandDescriptor.ts index 62288a2..485c07d 100644 --- a/src/CommandDescriptor.ts +++ b/src/CommandDescriptor.ts @@ -170,24 +170,6 @@ export const mapOrFail: { (self: Command, f: (a: A) => Either): Command } = Internal.mapOrFail -/** - * @since 1.0.0 - * @category combinators - */ -export const orElse: { - (that: Command): (self: Command) => Command - (self: Command, that: Command): Command -} = Internal.orElse - -/** - * @since 1.0.0 - * @category combinators - */ -export const orElseEither: { - (that: Command): (self: Command) => Command> - (self: Command, that: Command): Command> -} = Internal.orElseEither - /** * @since 1.0.0 * @category combinators From 4483763293cfed5d0ca8d21538190b26abdb914d Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:28:34 +1300 Subject: [PATCH 29/39] add accessors to Command module --- src/Command.ts | 60 +++++++++++++++++++++++++++++++++++++++++ src/internal/command.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/Command.ts b/src/Command.ts index fb24b18..bd9b6b7 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -3,6 +3,8 @@ */ import type { Tag } from "effect/Context" import type { Effect } from "effect/Effect" +import type { HashMap } from "effect/HashMap" +import type { HashSet } from "effect/HashSet" import type { Option } from "effect/Option" import { type Pipeable } from "effect/Pipeable" import type * as Types from "effect/Types" @@ -14,6 +16,7 @@ import type { Span } from "./HelpDoc/Span.js" import * as Internal from "./internal/command.js" import type { Options } from "./Options.js" import type { Prompt } from "./Prompt.js" +import type { Usage } from "./Usage.js" import type { ValidationError } from "./ValidationError.js" /** @@ -134,6 +137,63 @@ export const fromDescriptor: { ): Command } = Internal.fromDescriptor +/** + * @since 1.0.0 + * @category accessors + */ +export const getHelp: (self: Command) => HelpDoc = + Internal.getHelp + +/** + * @since 1.0.0 + * @category accessors + */ +export const getNames: ( + self: Command +) => HashSet = Internal.getNames + +/** + * @since 1.0.0 + * @category accessors + */ +export const getBashCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getBashCompletions + +/** + * @since 1.0.0 + * @category accessors + */ +export const getFishCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getFishCompletions + +/** + * @since 1.0.0 + * @category accessors + */ +export const getZshCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getZshCompletions + +/** + * @since 1.0.0 + * @category accessors + */ +export const getSubcommands: ( + self: Command +) => HashMap> = Internal.getSubcommands + +/** + * @since 1.0.0 + * @category accessors + */ +export const getUsage: (self: Command) => Usage = + Internal.getUsage + /** * @since 1.0.0 * @category constructors diff --git a/src/internal/command.ts b/src/internal/command.ts index 7e16b48..e94da67 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect" import * as Effectable from "effect/Effectable" import { dual } from "effect/Function" import { globalValue } from "effect/GlobalValue" +import type { HashMap } from "effect/HashMap" +import type { HashSet } from "effect/HashSet" import type * as Option from "effect/Option" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" @@ -15,6 +17,7 @@ import type { HelpDoc } from "../HelpDoc.js" import type { Span } from "../HelpDoc/Span.js" import type * as Options from "../Options.js" import type * as Prompt from "../Prompt.js" +import type { Usage } from "../Usage.js" import * as ValidationError from "../ValidationError.js" import * as InternalArgs from "./args.js" import * as InternalCliApp from "./cliApp.js" @@ -216,6 +219,48 @@ export const make: { handler?: (_: any) => Effect.Effect ) => fromDescriptor(makeDescriptor(name, config) as any, handler as any) as any +/** @internal */ +export const getHelp = ( + self: Command.Command +): HelpDoc => InternalDescriptor.getHelp(self.descriptor) + +/** @internal */ +export const getNames = ( + self: Command.Command +): HashSet => InternalDescriptor.getNames(self.descriptor) + +/** @internal */ +export const getBashCompletions = ( + self: Command.Command, + programName: string +): Effect.Effect> => + InternalDescriptor.getBashCompletions(self.descriptor, programName) + +/** @internal */ +export const getFishCompletions = ( + self: Command.Command, + programName: string +): Effect.Effect> => + InternalDescriptor.getFishCompletions(self.descriptor, programName) + +/** @internal */ +export const getZshCompletions = ( + self: Command.Command, + programName: string +): Effect.Effect> => + InternalDescriptor.getZshCompletions(self.descriptor, programName) + +/** @internal */ +export const getSubcommands = ( + self: Command.Command +): HashMap> => + InternalDescriptor.getSubcommands(self.descriptor) + +/** @internal */ +export const getUsage = ( + self: Command.Command +): Usage => InternalDescriptor.getUsage(self.descriptor) + /** @internal */ export const mapDescriptor = dual< (f: (_: Descriptor.Command) => Descriptor.Command) => ( From 4d434800afea5d44e95194fbbe8cb4618eb3058c Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:39:36 +1300 Subject: [PATCH 30/39] add Command.wizard --- src/Command.ts | 21 +++++++++++++++++++++ src/internal/command.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Command.ts b/src/Command.ts index bd9b6b7..af520c8 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,6 +1,8 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Tag } from "effect/Context" import type { Effect } from "effect/Effect" import type { HashMap } from "effect/HashMap" @@ -10,6 +12,7 @@ import { type Pipeable } from "effect/Pipeable" import type * as Types from "effect/Types" import type { Args } from "./Args.js" import type { CliApp } from "./CliApp.js" +import type { CliConfig } from "./CliConfig.js" import type * as Descriptor from "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" import type { Span } from "./HelpDoc/Span.js" @@ -312,6 +315,24 @@ export const withSubcommands: { > } = Internal.withSubcommands +/** + * @since 1.0.0 + * @category accessors + */ +export const wizard: { + ( + rootCommand: string, + config: CliConfig + ): ( + self: Command + ) => Effect> + ( + self: Command, + rootCommand: string, + config: CliConfig + ): Effect> +} = Internal.wizard + /** * @since 1.0.0 * @category conversions diff --git a/src/internal/command.ts b/src/internal/command.ts index e94da67..d7d48d4 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,3 +1,5 @@ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { QuitException, Terminal } from "@effect/platform/Terminal" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Effectable from "effect/Effectable" @@ -11,6 +13,7 @@ import * as ReadonlyArray from "effect/ReadonlyArray" import type * as Types from "effect/Types" import type * as Args from "../Args.js" import type * as CliApp from "../CliApp.js" +import type { CliConfig } from "../CliConfig.js" import type * as Command from "../Command.js" import type * as Descriptor from "../CommandDescriptor.js" import type { HelpDoc } from "../HelpDoc.js" @@ -397,6 +400,29 @@ export const withSubcommands = dual< return makeProto(command as any, handler, self.tag) as any }) +/** @internal */ +export const wizard = dual< + ( + rootCommand: string, + config: CliConfig + ) => ( + self: Command.Command + ) => Effect.Effect< + FileSystem | Terminal, + QuitException | ValidationError.ValidationError, + ReadonlyArray + >, + ( + self: Command.Command, + rootCommand: string, + config: CliConfig + ) => Effect.Effect< + FileSystem | Terminal, + QuitException | ValidationError.ValidationError, + ReadonlyArray + > +>(3, (self, rootCommand, config) => InternalDescriptor.wizard(self.descriptor, rootCommand, config)) + /** @internal */ export const run = dual< (config: { From 24fed34fb4447fb9c252fd55b27d6e53587bafd1 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 15:48:11 -0500 Subject: [PATCH 31/39] cleanup naval fate --- examples/naval-fate.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 119f597..1d0ff30 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -32,7 +32,7 @@ const speedOption = Options.integer("speed").pipe( Options.withDefault(10) ) -const shipCommandParent = Command.make("ship", { +const shipCommand = Command.make("ship", { verbose: Options.boolean("verbose") }) @@ -40,7 +40,7 @@ const newShipCommand = Command.make("new", { name: nameArg }, ({ name }) => Effect.gen(function*(_) { - const { verbose } = yield* _(shipCommandParent) + const { verbose } = yield* _(shipCommand) yield* _(createShip(name)) yield* _(Console.log(`Created ship: '${name}'`)) if (verbose) { @@ -67,13 +67,7 @@ const shootShipCommand = Command.make( }) ) -const shipCommand = Command.withSubcommands(shipCommandParent, [ - newShipCommand, - moveShipCommand, - shootShipCommand -]) - -const mineCommandParent = Command.make("mine") +const mineCommand = Command.make("mine") const setMineCommand = Command.make("set", { ...coordinatesArg, @@ -94,14 +88,12 @@ const removeMineCommand = Command.make("remove", { yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) })) -const mineCommand = Command.withSubcommands(mineCommandParent, [ - setMineCommand, - removeMineCommand -]) - const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), - Command.withSubcommands([shipCommand, mineCommand]), + Command.withSubcommands([ + Command.withSubcommands(shipCommand, [newShipCommand, moveShipCommand, shootShipCommand]), + Command.withSubcommands(mineCommand, [setMineCommand, removeMineCommand]) + ]), Command.run({ name: "Naval Fate", version: "1.0.0" From 79216cadb5bd1db7069927133e45038c3faae538 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:49:05 +1300 Subject: [PATCH 32/39] remove Command.mapDescriptor api --- src/Command.ts | 14 -------------- src/internal/command.ts | 18 +----------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/src/Command.ts b/src/Command.ts index af520c8..1e83799 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -231,20 +231,6 @@ export const make: { > } = Internal.make -/** - * @since 1.0.0 - * @category combinators - */ -export const mapDescriptor: { - ( - f: (_: Descriptor.Command) => Descriptor.Command - ): (self: Command) => Command - ( - self: Command, - f: (_: Descriptor.Command) => Descriptor.Command - ): Command -} = Internal.mapDescriptor - /** * @since 1.0.0 * @category constructors diff --git a/src/internal/command.ts b/src/internal/command.ts index d7d48d4..e0c2dad 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -264,8 +264,7 @@ export const getUsage = ( self: Command.Command ): Usage => InternalDescriptor.getUsage(self.descriptor) -/** @internal */ -export const mapDescriptor = dual< +const mapDescriptor = dual< (f: (_: Descriptor.Command) => Descriptor.Command) => ( self: Command.Command ) => Command.Command, @@ -275,21 +274,6 @@ export const mapDescriptor = dual< ) => Command.Command >(2, (self, f) => makeProto(f(getDescriptor(self)), self.handler, self.tag)) -/** @internal */ -export const mapBoth = dual< - ( - f: (_: Descriptor.Command) => Descriptor.Command, - g: (_: B) => A - ) => ( - self: Command.Command - ) => Command.Command, - ( - self: Command.Command, - f: (_: Descriptor.Command) => Descriptor.Command, - g: (_: B) => A - ) => Command.Command ->(3, (self, f, g) => makeProto(f(getDescriptor(self)), (_) => self.handler(g(_)), self.tag)) - /** @internal */ export const prompt = ( name: Name, From 7bf09a15c8e7243f469f3a7aeaa3143780a32d91 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:52:19 +1300 Subject: [PATCH 33/39] update example --- examples/naval-fate.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/naval-fate.ts b/examples/naval-fate.ts index 1d0ff30..644ab48 100644 --- a/examples/naval-fate.ts +++ b/examples/naval-fate.ts @@ -91,8 +91,15 @@ const removeMineCommand = Command.make("remove", { const run = Command.make("naval_fate").pipe( Command.withDescription("An implementation of the Naval Fate CLI application."), Command.withSubcommands([ - Command.withSubcommands(shipCommand, [newShipCommand, moveShipCommand, shootShipCommand]), - Command.withSubcommands(mineCommand, [setMineCommand, removeMineCommand]) + shipCommand.pipe(Command.withSubcommands([ + newShipCommand, + moveShipCommand, + shootShipCommand + ])), + mineCommand.pipe(Command.withSubcommands([ + setMineCommand, + removeMineCommand + ])) ]), Command.run({ name: "Naval Fate", From 42a5e7638a40f95c6708ea146388dfce97085f80 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 09:56:36 +1300 Subject: [PATCH 34/39] fix fromDescriptor --- package.json | 3 --- pnpm-lock.yaml | 28 +++++++++++++++++++++++----- src/internal/command.ts | 4 ++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a80ba5c..e918e8c 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,5 @@ "typescript": "^5.3.2", "vite": "^5.0.0", "vitest": "^0.34.6" - }, - "dependencies": { - "esbuild": "^0.19.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcd60c8..70b4768 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - esbuild: - specifier: ^0.19.8 - version: 0.19.8 - devDependencies: '@babel/cli': specifier: ^7.23.4 @@ -782,6 +777,7 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: true optional: true /@esbuild/android-arm@0.18.20: @@ -799,6 +795,7 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: true optional: true /@esbuild/android-x64@0.18.20: @@ -816,6 +813,7 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: true optional: true /@esbuild/darwin-arm64@0.18.20: @@ -833,6 +831,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: true optional: true /@esbuild/darwin-x64@0.18.20: @@ -850,6 +849,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: true optional: true /@esbuild/freebsd-arm64@0.18.20: @@ -867,6 +867,7 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: true optional: true /@esbuild/freebsd-x64@0.18.20: @@ -884,6 +885,7 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: true optional: true /@esbuild/linux-arm64@0.18.20: @@ -901,6 +903,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-arm@0.18.20: @@ -918,6 +921,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-ia32@0.18.20: @@ -935,6 +939,7 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-loong64@0.18.20: @@ -952,6 +957,7 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-mips64el@0.18.20: @@ -969,6 +975,7 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-ppc64@0.18.20: @@ -986,6 +993,7 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-riscv64@0.18.20: @@ -1003,6 +1011,7 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-s390x@0.18.20: @@ -1020,6 +1029,7 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/linux-x64@0.18.20: @@ -1037,6 +1047,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: true optional: true /@esbuild/netbsd-x64@0.18.20: @@ -1054,6 +1065,7 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: true optional: true /@esbuild/openbsd-x64@0.18.20: @@ -1071,6 +1083,7 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: true optional: true /@esbuild/sunos-x64@0.18.20: @@ -1088,6 +1101,7 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: true optional: true /@esbuild/win32-arm64@0.18.20: @@ -1105,6 +1119,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: true optional: true /@esbuild/win32-ia32@0.18.20: @@ -1122,6 +1137,7 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: true optional: true /@esbuild/win32-x64@0.18.20: @@ -1139,6 +1155,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: true optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.54.0): @@ -2910,6 +2927,7 @@ packages: '@esbuild/win32-arm64': 0.19.8 '@esbuild/win32-ia32': 0.19.8 '@esbuild/win32-x64': 0.19.8 + dev: true /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} diff --git a/src/internal/command.ts b/src/internal/command.ts index e0c2dad..7df6377 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -167,10 +167,10 @@ export const fromDescriptor = dual< >( (args) => InternalDescriptor.isCommand(args[0]), (descriptor: Descriptor.Command, handler?: (_: any) => Effect.Effect) => { - const command: Command.Command = makeProto( + const self: Command.Command = makeProto( descriptor, handler ?? - ((_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(command)))) + ((_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(self)))) ) return self as any } From e8c762586db67e6657b77c9717c94a56cec96e38 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 10:11:52 +1300 Subject: [PATCH 35/39] fix tests --- src/CommandDescriptor.ts | 4 +- src/internal/commandDescriptor.ts | 4 +- ...mand.test.ts => CommandDescriptor.test.ts} | 198 +++++++++--------- test/utils/grep.ts | 6 +- test/utils/tail.ts | 6 +- test/utils/wc.ts | 6 +- 6 files changed, 113 insertions(+), 111 deletions(-) rename test/{Command.test.ts => CommandDescriptor.test.ts} (71%) diff --git a/src/CommandDescriptor.ts b/src/CommandDescriptor.ts index 485c07d..99fb9a2 100644 --- a/src/CommandDescriptor.ts +++ b/src/CommandDescriptor.ts @@ -223,7 +223,7 @@ export const withDescription: { */ export const withSubcommands: { < - Subcommands extends readonly [ + const Subcommands extends readonly [ readonly [id: unknown, command: Command], ...Array]> ] @@ -238,7 +238,7 @@ export const withSubcommands: { > < A, - Subcommands extends readonly [ + const Subcommands extends readonly [ readonly [id: unknown, command: Command], ...Array]> ] diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index d9da44d..0a06af3 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -294,7 +294,7 @@ export const withDescription = dual< /** @internal */ export const withSubcommands = dual< < - Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< + const Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< readonly [id: unknown, command: Descriptor.Command] > >( @@ -309,7 +309,7 @@ export const withSubcommands = dual< >, < A, - Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< + const Subcommands extends ReadonlyArray.NonEmptyReadonlyArray< readonly [id: unknown, command: Descriptor.Command] > >( diff --git a/test/Command.test.ts b/test/CommandDescriptor.test.ts similarity index 71% rename from test/Command.test.ts rename to test/CommandDescriptor.test.ts index c96cee2..e708e31 100644 --- a/test/Command.test.ts +++ b/test/CommandDescriptor.test.ts @@ -1,7 +1,7 @@ import * as Args from "@effect/cli/Args" import * as BuiltInOptions from "@effect/cli/BuiltInOptions" import * as CliConfig from "@effect/cli/CliConfig" -import * as Command from "@effect/cli/Command" +import * as Descriptor from "@effect/cli/CommandDescriptor" import * as CommandDirective from "@effect/cli/CommandDirective" import * as HelpDoc from "@effect/cli/HelpDoc" import * as Options from "@effect/cli/Options" @@ -21,7 +21,10 @@ const MainLive = Layer.merge(FileSystem.layer, Terminal.layer) const runEffect = ( self: Effect.Effect -): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) +): Promise => + Effect.provide(self, MainLive).pipe( + Effect.runPromise + ) describe("Command", () => { describe("Standard Commands", () => { @@ -29,8 +32,8 @@ describe("Command", () => { Effect.gen(function*(_) { const args1 = ReadonlyArray.make("tail", "-n", "100", "foo.log") const args2 = ReadonlyArray.make("grep", "--after", "2", "--before", "3", "fooBar") - const result1 = yield* _(Command.parse(Tail.command, args1, CliConfig.defaultConfig)) - const result2 = yield* _(Command.parse(Grep.command, args2, CliConfig.defaultConfig)) + const result1 = yield* _(Descriptor.parse(Tail.command, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Descriptor.parse(Grep.command, args2, CliConfig.defaultConfig)) const expected1 = { name: "tail", options: 100, args: "foo.log" } const expected2 = { name: "grep", options: [2, 3], args: "fooBar" } expect(result1).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected1)) @@ -43,13 +46,13 @@ describe("Command", () => { const args2 = ReadonlyArray.make("grep", "--after", "2", "--efore", "3", "fooBar") const args3 = ReadonlyArray.make("grep", "--afte", "2", "--efore", "3", "fooBar") const result1 = yield* _( - Effect.flip(Command.parse(Grep.command, args1, CliConfig.defaultConfig)) + Effect.flip(Descriptor.parse(Grep.command, args1, CliConfig.defaultConfig)) ) const result2 = yield* _( - Effect.flip(Command.parse(Grep.command, args2, CliConfig.defaultConfig)) + Effect.flip(Descriptor.parse(Grep.command, args2, CliConfig.defaultConfig)) ) const result3 = yield* _( - Effect.flip(Command.parse(Grep.command, args3, CliConfig.defaultConfig)) + Effect.flip(Descriptor.parse(Grep.command, args3, CliConfig.defaultConfig)) ) expect(result1).toEqual(ValidationError.correctedFlag(HelpDoc.p( "The flag '--afte' is not recognized. Did you mean '--after'?" @@ -66,7 +69,7 @@ describe("Command", () => { Effect.gen(function*(_) { const args = ReadonlyArray.make("grep", "--a", "2", "--before", "3", "fooBar") const result = yield* _( - Effect.flip(Command.parse(Grep.command, args, CliConfig.defaultConfig)) + Effect.flip(Descriptor.parse(Grep.command, args, CliConfig.defaultConfig)) ) expect(result).toEqual(ValidationError.missingValue(HelpDoc.sequence( HelpDoc.p("Expected to find option: '--after'"), @@ -75,24 +78,17 @@ describe("Command", () => { }).pipe(runEffect)) }) - describe("Alternative Commands", () => { - it("should handle alternative commands", () => - Effect.gen(function*(_) { - const args = ReadonlyArray.of("log") - const command = Command.make("remote").pipe(Command.orElse(Command.make("log"))) - const result = yield* _(Command.parse(command, args, CliConfig.defaultConfig)) - const expected = { name: "log", options: void 0, args: void 0 } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - }) - describe("Commands with Clustered Options", () => { it("should treat clustered boolean options as un-clustered options", () => Effect.gen(function*(_) { const args1 = ReadonlyArray.make("wc", "-clw", "filename") const args2 = ReadonlyArray.make("wc", "-c", "-l", "-w", "filename") - const result1 = yield* _(Command.parse(WordCount.command, args1, CliConfig.defaultConfig)) - const result2 = yield* _(Command.parse(WordCount.command, args2, CliConfig.defaultConfig)) + const result1 = yield* _( + Descriptor.parse(WordCount.command, args1, CliConfig.defaultConfig) + ) + const result2 = yield* _( + Descriptor.parse(WordCount.command, args2, CliConfig.defaultConfig) + ) const expected = { name: "wc", options: [true, true, true, true], args: ["filename"] } expect(result1).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) expect(result2).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) @@ -101,7 +97,7 @@ describe("Command", () => { it("should not uncluster wrong clusters", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("wc", "-clk") - const result = yield* _(Command.parse(WordCount.command, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(WordCount.command, args, CliConfig.defaultConfig)) const expected = { name: "wc", options: [false, false, false, true], args: ["-clk"] } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -109,7 +105,7 @@ describe("Command", () => { it("should not alter '-'", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("wc", "-") - const result = yield* _(Command.parse(WordCount.command, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(WordCount.command, args, CliConfig.defaultConfig)) const expected = { name: "wc", options: [false, false, false, true], args: ["-"] } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -118,15 +114,15 @@ describe("Command", () => { describe("Subcommands without Options or Arguments", () => { const options = Options.boolean("verbose").pipe(Options.withAlias("v")) - const git = Command.make("git", { options }).pipe(Command.withSubcommands([ - Command.make("remote"), - Command.make("log") + const git = Descriptor.make("git", options).pipe(Descriptor.withSubcommands([ + ["remote", Descriptor.make("remote")], + ["log", Descriptor.make("log")] ])) it("should match the top-level command if no subcommands are specified", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "-v") - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: true, args: void 0, subcommand: Option.none() } expect(result).toEqual(CommandDirective.userDefined([], expected)) }).pipe(runEffect)) @@ -134,12 +130,12 @@ describe("Command", () => { it("should match the first subcommand without any surplus arguments", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "remote") - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: false, args: void 0, - subcommand: Option.some({ name: "remote", options: void 0, args: void 0 }) + subcommand: Option.some(["remote", { name: "remote", options: void 0, args: void 0 }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -147,12 +143,12 @@ describe("Command", () => { it("matches the first subcommand with a surplus option", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "remote", "-v") - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: false, args: void 0, - subcommand: Option.some({ name: "remote", options: void 0, args: void 0 }) + subcommand: Option.some(["remote", { name: "remote", options: void 0, args: void 0 }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.of("-v"), expected)) }).pipe(runEffect)) @@ -160,12 +156,12 @@ describe("Command", () => { it("matches the second subcommand without any surplus arguments", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "log") - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: false, args: void 0, - subcommand: Option.some({ name: "log", options: void 0, args: void 0 }) + subcommand: Option.some(["log", { name: "log", options: void 0, args: void 0 }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -173,7 +169,7 @@ describe("Command", () => { it("should return an error message for an unknown subcommand", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "abc") - const result = yield* _(Effect.flip(Command.parse(git, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(Descriptor.parse(git, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.commandMismatch(HelpDoc.p( "Invalid subcommand for git - use one of 'log', 'remote'" ))) @@ -188,23 +184,23 @@ describe("Command", () => { const args = Args.all([Args.text(), Args.text()]) - const git = Command.make("git").pipe(Command.withSubcommands([ - Command.make("rebase", { options, args }) + const git = Descriptor.make("git").pipe(Descriptor.withSubcommands([ + ["rebase", Descriptor.make("rebase", options, args)] ])) it("should parse a subcommand with required options and arguments", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("git", "rebase", "-i", "upstream", "branch") - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: void 0, args: void 0, - subcommand: Option.some({ + subcommand: Option.some(["rebase", { name: "rebase", options: [true, "drop"], args: ["upstream", "branch"] - }) + }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -220,46 +216,49 @@ describe("Command", () => { "upstream", "branch" ) - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) const expected = { name: "git", options: void 0, args: void 0, - subcommand: Option.some({ + subcommand: Option.some(["rebase", { name: "rebase", options: [true, "ask"], args: ["upstream", "branch"] - }) + }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) }) describe("Nested Subcommands", () => { - const command = Command.make("command").pipe(Command.withSubcommands([ - Command.make("sub").pipe(Command.withSubcommands([ - Command.make("subsub", { options: Options.boolean("i"), args: Args.text() }) - ])) + const command = Descriptor.make("command").pipe(Descriptor.withSubcommands([ + [ + "sub", + Descriptor.make("sub").pipe(Descriptor.withSubcommands([ + ["subsub", Descriptor.make("subsub", Options.boolean("i"), Args.text())] + ])) + ] ])) it("should properly parse deeply nested subcommands with options and arguments", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("command", "sub", "subsub", "-i", "text") - const result = yield* _(Command.parse(command, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(command, args, CliConfig.defaultConfig)) const expected = { name: "command", options: void 0, args: void 0, - subcommand: Option.some({ + subcommand: Option.some(["sub", { name: "sub", options: void 0, args: void 0, - subcommand: Option.some({ + subcommand: Option.some(["subsub", { name: "subsub", options: true, args: "text" - }) - }) + }]) + }]) } expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) }).pipe(runEffect)) @@ -268,39 +267,39 @@ describe("Command", () => { describe("Help Documentation", () => { it("should allow adding help documentation to a command", () => Effect.gen(function*(_) { - const cmd = Command.make("tldr").pipe(Command.withDescription("this is some help")) + const cmd = Descriptor.make("tldr").pipe(Descriptor.withDescription("this is some help")) const args = ReadonlyArray.of("tldr") - const result = yield* _(Command.parse(cmd, args, CliConfig.defaultConfig)) + const result = yield* _(Descriptor.parse(cmd, args, CliConfig.defaultConfig)) const expectedValue = { name: "tldr", options: void 0, args: void 0 } const expectedDoc = HelpDoc.sequence( HelpDoc.h1("DESCRIPTION"), HelpDoc.p("this is some help") ) expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expectedValue)) - expect(Command.getHelp(cmd)).toEqual(expectedDoc) + expect(Descriptor.getHelp(cmd)).toEqual(expectedDoc) }).pipe(runEffect)) it("should allow adding help documentation to subcommands", () => { - const cmd = Command.make("command").pipe(Command.withSubcommands([ - Command.make("sub").pipe(Command.withDescription("this is some help")) + const cmd = Descriptor.make("command").pipe(Descriptor.withSubcommands([ + ["sub", Descriptor.make("sub").pipe(Descriptor.withDescription("this is some help"))] ])) const expected = HelpDoc.sequence(HelpDoc.h1("DESCRIPTION"), HelpDoc.p("this is some help")) - expect(Command.getHelp(cmd)).not.toEqual(expected) + expect(Descriptor.getHelp(cmd)).not.toEqual(expected) }) it("should correctly display help documentation for a command", () => { - const child3 = Command.make("child3").pipe(Command.withDescription("help 3")) - const child2 = Command.make("child2").pipe( - Command.withDescription("help 2"), - Command.orElse(child3) + const child2 = Descriptor.make("child2").pipe( + Descriptor.withDescription("help 2") + ) + const child1 = Descriptor.make("child1").pipe( + Descriptor.withSubcommands([["child2", child2]]), + Descriptor.withDescription("help 1") ) - const child1 = Command.make("child1").pipe( - Command.withSubcommands([child2]), - Command.withDescription("help 1") + const parent = Descriptor.make("parent").pipe( + Descriptor.withSubcommands([["child1", child1]]) ) - const parent = Command.make("parent").pipe(Command.withSubcommands([child1])) const result = Render.prettyDefault( - Doc.unAnnotate(HelpDoc.toAnsiDoc(Command.getHelp(parent))) + Doc.unAnnotate(HelpDoc.toAnsiDoc(Descriptor.getHelp(parent))) ) expect(result).toBe(String.stripMargin( `|COMMANDS @@ -308,15 +307,13 @@ describe("Command", () => { | - child1 help 1 | | - child1 child2 help 2 - | - | - child1 child3 help 3 |` )) }) }) describe("Built-In Options Processing", () => { - const command = Command.make("command", { options: Options.text("a") }) + const command = Descriptor.make("command", Options.text("a")) const params1 = ReadonlyArray.make("command", "--help") const params2 = ReadonlyArray.make("command", "-h") const params3 = ReadonlyArray.make("command", "--wizard") @@ -344,19 +341,19 @@ describe("Command", () => { it("should trigger built-in options if they are alone", () => Effect.gen(function*(_) { const result1 = yield* _( - Command.parse(command, params1, CliConfig.defaultConfig), + Descriptor.parse(command, params1, CliConfig.defaultConfig), Effect.map(directiveType) ) const result2 = yield* _( - Command.parse(command, params2, CliConfig.defaultConfig), + Descriptor.parse(command, params2, CliConfig.defaultConfig), Effect.map(directiveType) ) const result3 = yield* _( - Command.parse(command, params3, CliConfig.defaultConfig), + Descriptor.parse(command, params3, CliConfig.defaultConfig), Effect.map(directiveType) ) const result4 = yield* _( - Command.parse(command, params4, CliConfig.defaultConfig), + Descriptor.parse(command, params4, CliConfig.defaultConfig), Effect.map(directiveType) ) expect(result1).toBe("help") @@ -368,7 +365,7 @@ describe("Command", () => { it("should not trigger help if an option matches", () => Effect.gen(function*(_) { const result = yield* _( - Command.parse(command, params5, CliConfig.defaultConfig), + Descriptor.parse(command, params5, CliConfig.defaultConfig), Effect.map(directiveType) ) expect(result).toBe("user") @@ -378,11 +375,11 @@ describe("Command", () => { Effect.gen(function*(_) { const config = CliConfig.make({ finalCheckBuiltIn: true }) const result1 = yield* _( - Command.parse(command, params6, config), + Descriptor.parse(command, params6, config), Effect.map(directiveType) ) const result2 = yield* _( - Command.parse(command, params7, config), + Descriptor.parse(command, params7, config), Effect.map(directiveType) ) expect(result1).toBe("help") @@ -393,7 +390,7 @@ describe("Command", () => { Effect.gen(function*(_) { const config = CliConfig.make({ finalCheckBuiltIn: true }) const result = yield* _( - Command.parse(command, params8, config), + Descriptor.parse(command, params8, config), Effect.map(directiveType) ) expect(result).toBe("wizard") @@ -401,22 +398,23 @@ describe("Command", () => { }) describe("End of Command Options Symbol", () => { - const command = Command.make("cmd", { - options: Options.all([ + const command = Descriptor.make( + "cmd", + Options.all([ Options.optional(Options.text("something")), Options.boolean("verbose").pipe(Options.withAlias("v")) ]), - args: Args.repeated(Args.text()) - }) + Args.repeated(Args.text()) + ) it("should properly handle the end of command options symbol", () => Effect.gen(function*(_) { const args1 = ReadonlyArray.make("cmd", "-v", "--something", "abc", "something") const args2 = ReadonlyArray.make("cmd", "-v", "--", "--something", "abc", "something") const args3 = ReadonlyArray.make("cmd", "--", "-v", "--something", "abc", "something") - const result1 = yield* _(Command.parse(command, args1, CliConfig.defaultConfig)) - const result2 = yield* _(Command.parse(command, args2, CliConfig.defaultConfig)) - const result3 = yield* _(Command.parse(command, args3, CliConfig.defaultConfig)) + const result1 = yield* _(Descriptor.parse(command, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Descriptor.parse(command, args2, CliConfig.defaultConfig)) + const result3 = yield* _(Descriptor.parse(command, args3, CliConfig.defaultConfig)) const expected1 = { name: "cmd", options: [Option.some("abc"), true], @@ -439,23 +437,27 @@ describe("Command", () => { }) describe("Completions", () => { - const command = Command.make("forge").pipe(Command.withSubcommands([ - Command.make("cache", { - options: Options.boolean("verbose").pipe( - Options.withDescription("Output in verbose mode") + const command = Descriptor.make("forge").pipe(Descriptor.withSubcommands([ + [ + "cache", + Descriptor.make( + "cache", + Options.boolean("verbose").pipe( + Options.withDescription("Output in verbose mode") + ) + ).pipe( + Descriptor.withDescription("The cache command does cache things"), + Descriptor.withSubcommands([ + ["clean", Descriptor.make("clean")], + ["ls", Descriptor.make("ls")] + ]) ) - }).pipe( - Command.withDescription("The cache command does cache things"), - Command.withSubcommands([ - Command.make("clean"), - Command.make("ls") - ]) - ) + ] ])) it("should create completions for the bash shell", () => Effect.gen(function*(_) { - const result = yield* _(Command.getBashCompletions(command, "forge")) + const result = yield* _(Descriptor.getBashCompletions(command, "forge")) yield* _( Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/bash-completions")) ) @@ -463,7 +465,7 @@ describe("Command", () => { it("should create completions for the zsh shell", () => Effect.gen(function*(_) { - const result = yield* _(Command.getZshCompletions(command, "forge")) + const result = yield* _(Descriptor.getZshCompletions(command, "forge")) yield* _( Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/zsh-completions")) ) @@ -471,7 +473,7 @@ describe("Command", () => { it("should create completions for the fish shell", () => Effect.gen(function*(_) { - const result = yield* _(Command.getFishCompletions(command, "forge")) + const result = yield* _(Descriptor.getFishCompletions(command, "forge")) yield* _( Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/fish-completions")) ) diff --git a/test/utils/grep.ts b/test/utils/grep.ts index 94723e8..5188c87 100644 --- a/test/utils/grep.ts +++ b/test/utils/grep.ts @@ -1,5 +1,5 @@ import * as Args from "@effect/cli/Args" -import * as Command from "@effect/cli/Command" +import * as Descriptor from "@effect/cli/CommandDescriptor" import * as Options from "@effect/cli/Options" const afterFlag = Options.integer("after").pipe(Options.withAlias("A")) @@ -11,8 +11,8 @@ export const options: Options.Options<[number, number]> = Options.all([ export const args: Args.Args = Args.text() -export const command: Command.Command<{ +export const command: Descriptor.Command<{ readonly name: "grep" readonly options: [number, number] readonly args: string -}> = Command.make("grep", { options, args }) +}> = Descriptor.make("grep", options, args) diff --git a/test/utils/tail.ts b/test/utils/tail.ts index 44e24e8..6f04e43 100644 --- a/test/utils/tail.ts +++ b/test/utils/tail.ts @@ -1,5 +1,5 @@ import * as Args from "@effect/cli/Args" -import * as Command from "@effect/cli/Command" +import * as Descriptor from "@effect/cli/CommandDescriptor" import * as Options from "@effect/cli/Options" export const options: Options.Options = Options.integer("n").pipe( @@ -8,8 +8,8 @@ export const options: Options.Options = Options.integer("n").pipe( export const args: Args.Args = Args.file({ name: "file" }) -export const command: Command.Command<{ +export const command: Descriptor.Command<{ readonly name: "tail" readonly options: number readonly args: string -}> = Command.make("tail", { options, args }) +}> = Descriptor.make("tail", options, args) diff --git a/test/utils/wc.ts b/test/utils/wc.ts index 982659e..86a3417 100644 --- a/test/utils/wc.ts +++ b/test/utils/wc.ts @@ -1,5 +1,5 @@ import * as Args from "@effect/cli/Args" -import * as Command from "@effect/cli/Command" +import * as Descriptor from "@effect/cli/CommandDescriptor" import * as Options from "@effect/cli/Options" const bytesFlag = Options.boolean("c") @@ -15,8 +15,8 @@ export const options: Options.Options<[boolean, boolean, boolean, boolean]> = Op export const args: Args.Args> = Args.repeated(Args.file({ name: "files" })) -export const command: Command.Command<{ +export const command: Descriptor.Command<{ readonly name: "wc" readonly options: [boolean, boolean, boolean, boolean] readonly args: ReadonlyArray -}> = Command.make("wc", { options, args }) +}> = Descriptor.make("wc", options, args) From 38e529111ee5796eba8d85224ab99265cac5cae6 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 10:51:48 +1300 Subject: [PATCH 36/39] add some Command tests --- test/Command.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 test/Command.test.ts diff --git a/test/Command.test.ts b/test/Command.test.ts new file mode 100644 index 0000000..703380b --- /dev/null +++ b/test/Command.test.ts @@ -0,0 +1,96 @@ +import { Args, Command, Options } from "@effect/cli" +import { NodeContext } from "@effect/platform-node" +import { Context, Effect, Layer } from "effect" +import { assert, describe, test } from "vitest" + +const git = Command.make("git", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}) + +const clone = Command.make("clone", { + repository: Args.text({ name: "repository" }) +}, ({ repository }) => + Effect.gen(function*(_) { + const { log } = yield* _(Messages) + const { verbose } = yield* _(git) + if (verbose) { + yield* _(log(`Cloning ${repository}`)) + } else { + yield* _(log(`Cloning`)) + } + })) + +const add = Command.make("add", { + pathspec: Args.text({ name: "pathspec" }) +}, ({ pathspec }) => + Effect.gen(function*(_) { + const { log } = yield* _(Messages) + const { verbose } = yield* _(git) + if (verbose) { + yield* _(log(`Adding ${pathspec}`)) + } else { + yield* _(log(`Adding`)) + } + })) + +const run = git.pipe( + Command.withSubcommands([clone, add]), + Command.run({ + name: "git", + version: "1.0.0" + }) +) + +describe("Command", () => { + describe("git", () => { + test("no sub-command", () => + Effect.gen(function*(_) { + const messages = yield* _(Messages) + yield* _(run(["--verbose"])) + yield* _(run([])) + assert.deepStrictEqual(yield* _(messages.messages), []) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + test("add", () => + Effect.gen(function*(_) { + const messages = yield* _(Messages) + yield* _(run(["add", "file"])) + yield* _(run(["--verbose", "add", "file"])) + yield* _(run(["add", "--verbose", "file"])) + assert.deepStrictEqual(yield* _(messages.messages), [ + "Adding", + "Adding file", + "Adding" // TODO: probably should be "Cloning repo" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + test("clone", () => + Effect.gen(function*(_) { + const messages = yield* _(Messages) + yield* _(run(["clone", "repo"])) + yield* _(run(["--verbose", "clone", "repo"])) + yield* _(run(["clone", "--verbose", "repo"])) + assert.deepStrictEqual(yield* _(messages.messages), [ + "Cloning", + "Cloning repo", + "Cloning" // TODO: probably should be "Adding repo" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + }) +}) + +// -- + +interface Messages { + readonly log: (message: string) => Effect.Effect + readonly messages: Effect.Effect> +} +const Messages = Context.Tag() +const MessagesLive = Layer.sync(Messages, () => { + const messages: Array = [] + return Messages.of({ + log: (message) => Effect.sync(() => messages.push(message)), + messages: Effect.sync(() => messages) + }) +}) +const EnvLive = Layer.mergeAll(MessagesLive, NodeContext.layer) From b0d7e9f3ce777ea8c623badc73c05cce7b246961 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 10:57:54 +1300 Subject: [PATCH 37/39] update test TODOs --- test/Command.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Command.test.ts b/test/Command.test.ts index 703380b..fc02f20 100644 --- a/test/Command.test.ts +++ b/test/Command.test.ts @@ -60,7 +60,7 @@ describe("Command", () => { assert.deepStrictEqual(yield* _(messages.messages), [ "Adding", "Adding file", - "Adding" // TODO: probably should be "Cloning repo" + "Adding" // TODO: probably should be "Adding repo" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) @@ -73,7 +73,7 @@ describe("Command", () => { assert.deepStrictEqual(yield* _(messages.messages), [ "Cloning", "Cloning repo", - "Cloning" // TODO: probably should be "Adding repo" + "Cloning" // TODO: probably should be "Cloning repo" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) }) From 2b5fceb62f112d6b9a4ff04bd304c47cf7d33366 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 29 Nov 2023 14:10:24 +1300 Subject: [PATCH 38/39] register descriptors at point of consumption --- src/internal/command.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/internal/command.ts b/src/internal/command.ts index 7df6377..12d8dc9 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -124,13 +124,13 @@ const Prototype = { } } -const modifiedCommands = globalValue( - "@effect/cli/Command/modifiedCommands", +const registeredDescriptors = globalValue( + "@effect/cli/Command/registeredDescriptors", () => new WeakMap, Descriptor.Command>() ) const getDescriptor = (self: Command.Command) => - modifiedCommands.get(self.tag) ?? self.descriptor + registeredDescriptors.get(self.tag) ?? self.descriptor const makeProto = ( descriptor: Descriptor.Command, @@ -141,7 +141,6 @@ const makeProto = ( self.descriptor = descriptor self.handler = handler self.tag = tag ?? Context.Tag() - modifiedCommands.set(self.tag, self.descriptor) return self } @@ -359,9 +358,10 @@ export const withSubcommands = dual< ) const handlers = ReadonlyArray.reduce( subcommands, - new Map, (_: any) => Effect.Effect>(), + new Map, Command.Command>(), (handlers, subcommand) => { - handlers.set(subcommand.tag, subcommand.handler) + handlers.set(subcommand.tag, subcommand) + registeredDescriptors.set(subcommand.tag, subcommand.descriptor) return handlers } ) @@ -373,8 +373,9 @@ export const withSubcommands = dual< ) { if (args.subcommand._tag === "Some") { const [tag, value] = args.subcommand.value + const subcommand = handlers.get(tag)! return Effect.provideService( - handlers.get(tag)!(value), + subcommand.handler(value), self.tag, args as any ) @@ -432,5 +433,6 @@ export const run = dual< ...config, command: self.descriptor }) + registeredDescriptors.set(self.tag, self.descriptor) return (args) => InternalCliApp.run(app, args, self.handler) }) From 0249133ab104a8ba8af86a9d52c4ee5d24c7b9f4 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Tue, 28 Nov 2023 23:16:33 -0500 Subject: [PATCH 39/39] cleanup options parsing --- src/Options.ts | 12 +- src/internal/commandDescriptor.ts | 19 +- src/internal/options.ts | 541 +++++++++++++++--------------- src/internal/primitive.ts | 10 +- test/Options.test.ts | 151 ++++----- 5 files changed, 362 insertions(+), 371 deletions(-) diff --git a/src/Options.ts b/src/Options.ts index 1104189..54d01d7 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -398,18 +398,20 @@ export const parse: { export const repeated: (self: Options) => Options> = InternalOptions.repeated /** - * Parses the provided command-line arguments looking for the specified options, - * and returns an `Option`, any leftover arguments, and the + * Processes the provided command-line arguments, searching for the specified + * `Options`. + * + * Returns an `Option`, any leftover arguments, and the * constructed value of type `A`. The possible error inside * `Option` would only be triggered if there is an error when * parsing the command-line arguments. This is because `ValidationError`s are * also used internally to control the end of the command-line arguments (i.e. - * the command-line symbol `-`) corresponding to options. + * the command-line symbol `--`) corresponding to options. * * @since 1.0.0 * @category combinators */ -export const validate: { +export const processCommandLine: { ( args: ReadonlyArray, config: CliConfig @@ -429,7 +431,7 @@ export const validate: { ValidationError, [Option, ReadonlyArray, A] > -} = InternalOptions.validate +} = InternalOptions.processCommandLine /** * @since 1.0.0 diff --git a/src/internal/commandDescriptor.ts b/src/internal/commandDescriptor.ts index 0a06af3..175dd06 100644 --- a/src/internal/commandDescriptor.ts +++ b/src/internal/commandDescriptor.ts @@ -593,14 +593,15 @@ const parseInternal = ( const help = getHelpInternal(self) const usage = getUsageInternal(self) const options = InternalBuiltInOptions.builtInOptions(self, usage, help) - return InternalOptions.validate(options, ReadonlyArray.drop(args, 1), config).pipe( - Effect.flatMap((tuple) => tuple[2]), - Effect.catchTag("NoSuchElementException", () => { - const error = InternalHelpDoc.p("No built-in option was matched") - return Effect.fail(InternalValidationError.noBuiltInMatch(error)) - }), - Effect.map(InternalCommandDirective.builtIn) - ) + return InternalOptions.processCommandLine(options, ReadonlyArray.drop(args, 1), config) + .pipe( + Effect.flatMap((tuple) => tuple[2]), + Effect.catchTag("NoSuchElementException", () => { + const error = InternalHelpDoc.p("No built-in option was matched") + return Effect.fail(InternalValidationError.noBuiltInMatch(error)) + }), + Effect.map(InternalCommandDirective.builtIn) + ) } } const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) @@ -615,7 +616,7 @@ const parseInternal = ( > => parseCommandLine(args).pipe(Effect.flatMap((commandOptionsAndArgs) => { const [optionsAndArgs, forcedCommandArgs] = splitForcedArgs(commandOptionsAndArgs) - return InternalOptions.validate(self.options, optionsAndArgs, config).pipe( + return InternalOptions.processCommandLine(self.options, optionsAndArgs, config).pipe( Effect.flatMap(([error, commandArgs, optionsType]) => InternalArgs.validate( self.args, diff --git a/src/internal/options.ts b/src/internal/options.ts index 855ca18..e655e10 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -3,7 +3,7 @@ import type * as Terminal from "@effect/platform/Terminal" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" -import { absurd, dual, pipe } from "effect/Function" +import { dual, pipe } from "effect/Function" import * as HashMap from "effect/HashMap" import * as Option from "effect/Option" import * as Order from "effect/Order" @@ -455,11 +455,7 @@ export const parse = dual< >(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 validate = dual< +export const processCommandLine = dual< ( args: ReadonlyArray, config: CliConfig.CliConfig @@ -497,6 +493,10 @@ export const validate = dual< ) ) +/** @internal */ +export const repeated = (self: Options.Options): Options.Options> => + makeVariadic(self, Option.none(), Option.none()) + /** @internal */ export const withAlias = dual< (alias: string) => (self: Options.Options) => Options.Options, @@ -990,183 +990,6 @@ export const getNames = (self: Instruction): ReadonlyArray => { ) } -const parseOptions = ( - self: ParseableInstruction, - args: ReadonlyArray, - config: CliConfig.CliConfig -): Effect.Effect< - never, - ValidationError.ValidationError, - [ReadonlyArray, ReadonlyArray] -> => { - switch (self._tag) { - case "Single": { - return processArgs(args).pipe( - Effect.flatMap((args) => { - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - const head = ReadonlyArray.headNonEmpty(args) - const tail = ReadonlyArray.tailNonEmpty(args) - const normalizedArgv0 = InternalCliConfig.normalizeCase(config, head) - const normalizedNames = ReadonlyArray.map( - getNames(self), - (name) => InternalCliConfig.normalizeCase(config, name) - ) - if (ReadonlyArray.contains(normalizedNames, normalizedArgv0)) { - if (InternalPrimitive.isBool(self.primitiveType)) { - if (ReadonlyArray.isNonEmptyReadonlyArray(tail) && tail[0] === "true") { - return Effect.succeed([ - ReadonlyArray.make(head, "true"), - ReadonlyArray.drop(tail, 1) - ]) - } - if (ReadonlyArray.isNonEmptyReadonlyArray(tail) && tail[0] === "false") { - return Effect.succeed([ - ReadonlyArray.make(head, "false"), - ReadonlyArray.drop(tail, 1) - ]) - } - return Effect.succeed([ReadonlyArray.of(head), tail]) - } - if (ReadonlyArray.isNonEmptyReadonlyArray(tail)) { - return Effect.succeed([ - ReadonlyArray.make(head, tail[0]), - ReadonlyArray.drop(tail, 1) - ]) - } - const error = InternalHelpDoc.p( - `Expected a value following option: '${self.fullName}'` - ) - return Effect.fail(InternalValidationError.missingValue(error)) - } - if ( - self.name.length > config.autoCorrectLimit + 1 && - InternalAutoCorrect.levensteinDistance(head, self.fullName, config) <= - config.autoCorrectLimit - ) { - const error = InternalHelpDoc.p( - `The flag '${head}' is not recognized. Did you mean '${self.fullName}'?` - ) - return Effect.fail(InternalValidationError.correctedFlag(error)) - } - const error = InternalHelpDoc.p(`Expected to find option: '${self.fullName}'`) - return Effect.fail(InternalValidationError.missingFlag(error)) - } - const error = InternalHelpDoc.p(`Expected to find option: '${self.fullName}'`) - return Effect.fail(InternalValidationError.missingFlag(error)) - }) - ) - } - case "KeyValueMap": { - const singleNames = ReadonlyArray.map( - getNames(self.argumentOption), - (name) => InternalCliConfig.normalizeCase(config, name) - ) - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - let currentIndex = 0 - let inKeyValueOption = false - let keyValues = ReadonlyArray.empty() - let leftover = args as ReadonlyArray - while (currentIndex < leftover.length) { - const name = leftover[currentIndex].trim() - const normalizedName = InternalCliConfig.normalizeCase(config, name) - // Can be in the form of "--flag key1=value1 --flag key2=value2" - if (leftover.length >= 2 && ReadonlyArray.contains(singleNames, normalizedName)) { - // Attempt to parse the key/value - const currentValue = leftover[currentIndex + 1] - if (currentValue !== undefined) { - const keyValue = currentValue.trim() - const [key, value] = keyValue.split("=") - if (key !== undefined && value !== undefined) { - if (ReadonlyArray.isEmptyReadonlyArray(keyValues)) { - // Add the name to the head of the array on first value found - keyValues = ReadonlyArray.appendAll(keyValues, [name, keyValue]) - } else { - // Otherwise just add the value - keyValues = ReadonlyArray.append(keyValues, keyValue) - } - leftover = ReadonlyArray.appendAll( - // Take everything from the start of leftover to the current index - ReadonlyArray.take(leftover, currentIndex), - // Drop the current argument and its key/value from the leftover - ReadonlyArray.takeRight(leftover, leftover.length - (currentIndex + 2)) - ) - inKeyValueOption = true - } - } else { - currentIndex = currentIndex + 1 - } - } // The prior steps will parse out the name of the flag and the first - // key/value pair - this step is to parse out variadic repetitions of - // key/value pairs that may occcur after the initial flag (i.e. in the - // form "--flag key1=value1 key2=value2") - else if (inKeyValueOption && name.includes("=")) { - const [key, value] = name.split("=") - if (key !== undefined && value !== undefined) { - // The flag name should have already been added by this point, so - // no need to perform the check from the prior step - keyValues = ReadonlyArray.append(keyValues, name) - leftover = ReadonlyArray.appendAll( - // Take everything from the start of leftover to the current index - ReadonlyArray.take(leftover, currentIndex), - // Drop the current key/value from the leftover - ReadonlyArray.takeRight(leftover, leftover.length - (currentIndex + 1)) - ) - } - } else { - inKeyValueOption = false - currentIndex = currentIndex + 1 - } - } - return Effect.succeed([keyValues, leftover]) - } - return Effect.succeed([ReadonlyArray.empty(), args]) - } - case "Variadic": { - const singleNames = ReadonlyArray.map( - getNames(self.argumentOption), - (name) => InternalCliConfig.normalizeCase(config, name) - ) - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - let currentIndex = 0 - let values = ReadonlyArray.empty() - let leftover = args as ReadonlyArray - while (currentIndex < leftover.length) { - const name = leftover[currentIndex].trim() - const normalizedName = InternalCliConfig.normalizeCase(config, name) - if (leftover.length >= 2 && ReadonlyArray.contains(singleNames, normalizedName)) { - const currentValue = leftover[currentIndex + 1] - if (currentValue !== undefined) { - const value = leftover[currentIndex + 1].trim() - if (ReadonlyArray.isEmptyReadonlyArray(values)) { - // Add the name to the head of the array on first value found - values = ReadonlyArray.appendAll(values, [name, value]) - } else { - // Otherwise just add the value - values = ReadonlyArray.append(values, value) - } - leftover = ReadonlyArray.appendAll( - // Take everything from the start of leftover to the current index - ReadonlyArray.take(leftover, currentIndex), - // Drop the current argument and its value from the leftover - ReadonlyArray.takeRight(leftover, leftover.length - (currentIndex + 2)) - ) - } else { - currentIndex = currentIndex + 1 - } - } else { - currentIndex = currentIndex + 1 - } - } - return Effect.succeed([values, leftover]) - } - return Effect.succeed([ReadonlyArray.empty(), args]) - } - default: { - return absurd(self) - } - } -} - const toParseableInstruction = (self: Instruction): ReadonlyArray => { switch (self._tag) { case "Empty": { @@ -1368,7 +1191,7 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. Effect.flatMap((input) => { // There will always be at least one name in names const args = ReadonlyArray.make(getNames(self)[0]!, input as string) - return parseOptions(self, args, config).pipe(Effect.as(args)) + return parseCommandLine(self, args, config).pipe(Effect.as(args)) }), Effect.zipLeft(Console.log()) ) @@ -1477,45 +1300,6 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. // Parsing Internals // ============================================================================= -const CLUSTERED_REGEX = /^-{1}([^-]{2,}$)/ -const FLAG_REGEX = /^(--[^=]+)(?:=(.+))?$/ - -const escape = (string: string): string => { - return string - .replaceAll("\\", "\\\\") - .replaceAll("'", "'\\''") - .replaceAll("[", "\\[") - .replaceAll("]", "\\]") - .replaceAll(":", "\\:") - .replaceAll("$", "\\$") - .replaceAll("`", "\\`") - .replaceAll("(", "\\(") - .replaceAll(")", "\\)") -} - -const processArgs = ( - args: ReadonlyArray -): Effect.Effect> => { - if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { - const head = ReadonlyArray.headNonEmpty(args) - const tail = ReadonlyArray.tailNonEmpty(args) - if (CLUSTERED_REGEX.test(head.trim())) { - const unclustered = head.substring(1).split("").map((c) => `-${c}`) - return Effect.fail( - InternalValidationError.unclusteredFlag(InternalHelpDoc.empty, unclustered, tail) - ) - } - if (head.startsWith("--")) { - const result = FLAG_REGEX.exec(head) - if (result !== null && result[2] !== undefined) { - return Effect.succeed(ReadonlyArray.prependAll(tail, [result[1], result[2]])) - } - } - return Effect.succeed(args) - } - return Effect.succeed(ReadonlyArray.empty()) -} - /** * Returns a possible `ValidationError` when parsing the commands, leftover * arguments from `input` and a mapping between each flag and its values. @@ -1594,64 +1378,267 @@ const findOptions = ( ReadonlyArray, HashMap.HashMap> ] -> => { - if (ReadonlyArray.isNonEmptyReadonlyArray(options)) { - const head = ReadonlyArray.headNonEmpty(options) - const tail = ReadonlyArray.tailNonEmpty(options) - return parseOptions(head, input, config).pipe( - Effect.flatMap(([nameValues, leftover]) => { - if (ReadonlyArray.isNonEmptyReadonlyArray(nameValues)) { - const name = ReadonlyArray.headNonEmpty(nameValues) - const values: ReadonlyArray = ReadonlyArray.tailNonEmpty(nameValues) - return Effect.succeed([leftover, tail, HashMap.make([name, values])] as [ - ReadonlyArray, - ReadonlyArray, - HashMap.HashMap> - ]) - } - return findOptions(leftover, tail, config).pipe( - Effect.map(([otherArgs, otherOptions, map]) => - [otherArgs, ReadonlyArray.prepend(otherOptions, head), map] as [ - ReadonlyArray, - ReadonlyArray, - HashMap.HashMap> - ] - ) - ) - }), - Effect.catchTags({ - CorrectedFlag: (e) => - findOptions(input, tail, config).pipe( - Effect.catchSome(() => Option.some(Effect.fail(e))), - Effect.flatMap(([otherArgs, otherOptions, map]) => - Effect.fail(e).pipe( - Effect.when(() => HashMap.isEmpty(map)), - Effect.as([otherArgs, ReadonlyArray.prepend(otherOptions, head), map] as [ +> => + ReadonlyArray.matchLeft(options, { + onEmpty: () => Effect.succeed([input, ReadonlyArray.empty(), HashMap.empty()]), + onNonEmpty: (head, tail) => + parseCommandLine(head, input, config).pipe( + Effect.flatMap(({ leftover, parsed }) => + Option.match(parsed, { + onNone: () => + findOptions(leftover, tail, config).pipe(Effect.map(([nextArgs, nextOptions, map]) => + [nextArgs, ReadonlyArray.prepend(nextOptions, head), map] as [ ReadonlyArray, ReadonlyArray, HashMap.HashMap> - ]) - ) - ) - ), - MissingFlag: () => - findOptions(input, tail, config).pipe( - Effect.map(([otherArgs, otherOptions, map]) => - [otherArgs, ReadonlyArray.prepend(otherOptions, head), map] as [ + ] + )), + onSome: ({ name, values }) => + Effect.succeed([leftover, tail, HashMap.make([name, values])] as [ ReadonlyArray, ReadonlyArray, HashMap.HashMap> - ] + ]) + }) + ), + Effect.catchTags({ + CorrectedFlag: (e) => + findOptions(input, tail, config).pipe( + Effect.catchSome(() => Option.some(Effect.fail(e))), + Effect.flatMap(([otherArgs, otherOptions, map]) => + Effect.fail(e).pipe( + Effect.when(() => HashMap.isEmpty(map)), + Effect.as([otherArgs, ReadonlyArray.prepend(otherOptions, head), map] as [ + ReadonlyArray, + ReadonlyArray, + HashMap.HashMap> + ]) + ) + ) + ), + MissingFlag: () => + findOptions(input, tail, config).pipe( + Effect.map(([otherArgs, otherOptions, map]) => + [otherArgs, ReadonlyArray.prepend(otherOptions, head), map] as [ + ReadonlyArray, + ReadonlyArray, + HashMap.HashMap> + ] + ) + ), + UnclusteredFlag: (e) => + matchUnclustered(e.unclustered, e.rest, options, config).pipe( + Effect.catchAll(() => Effect.fail(e)) ) - ), - UnclusteredFlag: (e) => - matchUnclustered(e.unclustered, e.rest, options, config).pipe( - Effect.catchAll(() => Effect.fail(e)) + }) + ) + }) + +interface ParsedCommandLine { + readonly parsed: Option.Option<{ + readonly name: string + readonly values: ReadonlyArray + }> + readonly leftover: ReadonlyArray +} + +const CLUSTERED_REGEX = /^-{1}([^-]{2,}$)/ +const FLAG_REGEX = /^(--[^=]+)(?:=(.+))?$/ + +/** + * Normalizes the leading command-line argument by performing the following: + * 1. If a clustered series of short command-line options is encountered, + * uncluster the options and return a `ValidationError.UnclusteredFlag` + * to be handled later on in the parsing algorithm + * 2. If a long command-line option with a value is encountered, ensure that + * the option and it's value are separated (i.e. `--option=value` becomes + * ["--option", "value"]) + */ +const processArgs = ( + args: ReadonlyArray +): Effect.Effect> => + ReadonlyArray.matchLeft(args, { + onEmpty: () => Effect.succeed(ReadonlyArray.empty()), + onNonEmpty: (head, tail) => { + const value = head.trim() + // Attempt to match clustered short command-line arguments (i.e. `-abc`) + if (CLUSTERED_REGEX.test(value)) { + const unclustered = value.substring(1).split("").map((c) => `-${c}`) + return Effect.fail(InternalValidationError.unclusteredFlag( + InternalHelpDoc.empty, + unclustered, + tail + )) + } + // Attempt to match a long command-line argument and ensure the option and + // it's value have been separated and added back to the arguments + if (FLAG_REGEX.test(value)) { + const result = FLAG_REGEX.exec(value) + if (result !== null && result[2] !== undefined) { + return Effect.succeed>( + ReadonlyArray.appendAll([result[1], result[2]], tail) ) + } + } + // Otherwise return the original command-line arguments + return Effect.succeed(args) + } + }) + +/** + * Processes the command-line arguments for a parseable option, returning the + * parsed command line results, which inclue: + * - The name of the option and its associated value(s), if any + * - Any leftover command-line arguments + */ +const parseCommandLine = ( + self: ParseableInstruction, + args: ReadonlyArray, + config: CliConfig.CliConfig +): Effect.Effect< + never, + ValidationError.ValidationError, + ParsedCommandLine +> => { + switch (self._tag) { + case "Single": { + return processArgs(args).pipe(Effect.flatMap((args) => + ReadonlyArray.matchLeft(args, { + onEmpty: () => { + const error = InternalHelpDoc.p(`Expected to find option: '${self.fullName}'`) + return Effect.fail(InternalValidationError.missingFlag(error)) + }, + onNonEmpty: (head, tail) => { + const normalize = (value: string) => InternalCliConfig.normalizeCase(config, value) + const normalizedHead = normalize(head) + const normalizedNames = ReadonlyArray.map(getNames(self), normalize) + if (ReadonlyArray.contains(normalizedNames, normalizedHead)) { + if (InternalPrimitive.isBool(self.primitiveType)) { + return ReadonlyArray.matchLeft(tail, { + onEmpty: () => { + const parsed = Option.some({ name: head, values: ReadonlyArray.empty() }) + return Effect.succeed({ parsed, leftover: tail }) + }, + onNonEmpty: (value, leftover) => { + if (InternalPrimitive.isTrueValue(value)) { + const parsed = Option.some({ name: head, values: ReadonlyArray.of("true") }) + return Effect.succeed({ parsed, leftover }) + } + if (InternalPrimitive.isFalseValue(value)) { + const parsed = Option.some({ name: head, values: ReadonlyArray.of("false") }) + return Effect.succeed({ parsed, leftover }) + } + const parsed = Option.some({ name: head, values: ReadonlyArray.empty() }) + return Effect.succeed({ parsed, leftover: tail }) + } + }) + } + return ReadonlyArray.matchLeft(tail, { + onEmpty: () => { + const error = InternalHelpDoc.p( + `Expected a value following option: '${self.fullName}'` + ) + return Effect.fail(InternalValidationError.missingValue(error)) + }, + onNonEmpty: (value, leftover) => { + const parsed = Option.some({ name: head, values: ReadonlyArray.of(value) }) + return Effect.succeed({ parsed, leftover }) + } + }) + } + if ( + self.name.length > config.autoCorrectLimit + 1 && + InternalAutoCorrect.levensteinDistance(head, self.fullName, config) <= + config.autoCorrectLimit + ) { + const error = InternalHelpDoc.p( + `The flag '${head}' is not recognized. Did you mean '${self.fullName}'?` + ) + return Effect.fail(InternalValidationError.correctedFlag(error)) + } + const error = InternalHelpDoc.p(`Expected to find option: '${self.fullName}'`) + return Effect.fail(InternalValidationError.missingFlag(error)) + } + }) + )) + } + case "KeyValueMap": { + const singleNames = ReadonlyArray.map( + getNames(self.argumentOption), + (name) => InternalCliConfig.normalizeCase(config, name) + ) + return ReadonlyArray.matchLeft(args, { + onEmpty: () => Effect.succeed({ parsed: Option.none(), leftover: args }), + onNonEmpty: (head, tail) => { + const loop = ( + args: ReadonlyArray + ): [ReadonlyArray, ReadonlyArray] => { + let keyValues = ReadonlyArray.empty() + let leftover = args as ReadonlyArray + while (ReadonlyArray.isNonEmptyReadonlyArray(leftover)) { + const name = leftover[0].trim() + const normalizedName = InternalCliConfig.normalizeCase(config, name) + // Can be in the form of "--flag key1=value1 --flag key2=value2" + if (leftover.length >= 2 && ReadonlyArray.contains(singleNames, normalizedName)) { + const keyValue = leftover[1].trim() + const [key, value] = keyValue.split("=") + if (key !== undefined && value !== undefined && value.length > 0) { + keyValues = ReadonlyArray.append(keyValues, keyValue) + leftover = leftover.slice(2) + continue + } + } + // Can be in the form of "--flag key1=value1 key2=value2") + if (name.includes("=")) { + const [key, value] = name.split("=") + if (key !== undefined && value !== undefined && value.length > 0) { + keyValues = ReadonlyArray.append(keyValues, name) + leftover = leftover.slice(1) + continue + } + } + break + } + return [keyValues, leftover] + } + const name = InternalCliConfig.normalizeCase(config, head) + if (ReadonlyArray.contains(singleNames, name)) { + const [values, leftover] = loop(tail) + return Effect.succeed({ parsed: Option.some({ name, values }), leftover }) + } + return Effect.succeed({ parsed: Option.none(), leftover: args }) + } }) - ) + } + case "Variadic": { + const singleNames = ReadonlyArray.map( + getNames(self.argumentOption), + (name) => InternalCliConfig.normalizeCase(config, name) + ) + let optionName: string | undefined = undefined + let values = ReadonlyArray.empty() + let leftover = args as ReadonlyArray + while (ReadonlyArray.isNonEmptyReadonlyArray(leftover)) { + const name = InternalCliConfig.normalizeCase(config, ReadonlyArray.headNonEmpty(leftover)) + if (leftover.length >= 2 && ReadonlyArray.contains(singleNames, name)) { + if (optionName === undefined) { + optionName = name + } + const value = leftover[1] + if (value !== undefined && value.length > 0) { + values = ReadonlyArray.append(values, value.trim()) + leftover = leftover.slice(2) + continue + } + break + } + } + const parsed = Option.fromNullable(optionName).pipe( + Option.map((name) => ({ name, values })) + ) + return Effect.succeed({ parsed, leftover }) + } } - return Effect.succeed([input, ReadonlyArray.empty(), HashMap.empty()]) } const matchUnclustered = ( @@ -1716,6 +1703,18 @@ const merge = ( // Completion Internals // ============================================================================= +const escape = (string: string): string => + string + .replaceAll("\\", "\\\\") + .replaceAll("'", "'\\''") + .replaceAll("[", "\\[") + .replaceAll("]", "\\]") + .replaceAll(":", "\\:") + .replaceAll("$", "\\$") + .replaceAll("`", "\\`") + .replaceAll("(", "\\(") + .replaceAll(")", "\\)") + const getShortDescription = (self: Instruction): string => { switch (self._tag) { case "Empty": diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 7f8bddc..e4ce005 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -129,9 +129,15 @@ export const isTextType = (self: Instruction): self is Text => self._tag === "Te /** @internal */ export const trueValues = Schema.literal("true", "1", "y", "yes", "on") +/** @internal */ +export const isTrueValue = Schema.is(trueValues) + /** @internal */ export const falseValues = Schema.literal("false", "0", "n", "no", "off") +/** @internal */ +export const isFalseValue = Schema.is(falseValues) + /** @internal */ export const boolean = (defaultValue: Option.Option): Primitive.Primitive => { const op = Object.create(proto) @@ -367,9 +373,9 @@ const validateInternal = ( () => `Missing default value for boolean parameter` ), onSome: (value) => - Schema.is(trueValues)(value) + isTrueValue(value) ? Effect.succeed(true) - : Schema.is(falseValues)(value) + : isFalseValue(value) ? Effect.succeed(false) : Effect.fail(`Unable to recognize '${value}' as a valid boolean`) }) diff --git a/test/Options.test.ts b/test/Options.test.ts index 97f9f6d..e4e7c64 100644 --- a/test/Options.test.ts +++ b/test/Options.test.ts @@ -25,7 +25,7 @@ const runEffect = ( self: Effect.Effect ): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) -const validation = ( +const process = ( options: Options.Options, args: ReadonlyArray, config: CliConfig.CliConfig @@ -34,7 +34,7 @@ const validation = ( ValidationError.ValidationError, [ReadonlyArray, A] > => - Options.validate(options, args, config).pipe( + Options.processCommandLine(options, args, config).pipe( Effect.flatMap(([err, rest, a]) => Option.match(err, { onNone: () => Effect.succeed([rest, a]), @@ -48,10 +48,10 @@ describe("Options", () => { Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstName", "--lastName", "--lastName", "--firstName") const result1 = yield* _( - validation(Options.all([firstName, lastName]), args, CliConfig.defaultConfig) + process(Options.all([firstName, lastName]), args, CliConfig.defaultConfig) ) const result2 = yield* _( - validation(Options.all([lastName, firstName]), args, CliConfig.defaultConfig) + process(Options.all([lastName, firstName]), args, CliConfig.defaultConfig) ) const expected1 = [ReadonlyArray.empty(), ReadonlyArray.make("--lastName", "--firstName")] const expected2 = [ReadonlyArray.empty(), ReadonlyArray.make("--firstName", "--lastName")] @@ -62,23 +62,15 @@ describe("Options", () => { it("should not uncluster values", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstName", "-ab") - const result = yield* _(validation(firstName, args, CliConfig.defaultConfig)) + const result = yield* _(process(firstName, args, CliConfig.defaultConfig)) const expected = [ReadonlyArray.empty(), "-ab"] expect(result).toEqual(expected) }).pipe(runEffect)) - it("should parse variadic options regardless of position", () => - Effect.gen(function*(_) { - const option = Options.integer("foo").pipe(Options.repeated) - const args = ["--foo", "1", "arg", "--foo", "2"] - const result = yield* _(validation(option, args, CliConfig.defaultConfig)) - expect(result).toEqual([ReadonlyArray.of("arg"), [1, 2]]) - }).pipe(runEffect)) - it("should return a HelpDoc if an option is not an exact match and it's a short option", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--ag", "20") - const result = yield* _(Effect.flip(validation(age, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(age, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--age'" ))) @@ -91,7 +83,7 @@ describe("Options", () => { Options.text("b").pipe(Options.map(identity)) ) const args = ReadonlyArray.make("-a", "a", "-b", "b") - const result = yield* _(Effect.flip(validation(options, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(options, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( "Collision between two options detected - you can only " + "specify one of either: ['-a', '-b']" @@ -101,7 +93,7 @@ describe("Options", () => { it("validates a boolean option without a value", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--verbose") - const result = yield* _(validation(verbose, args, CliConfig.defaultConfig)) + const result = yield* _(process(verbose, args, CliConfig.defaultConfig)) const expected = [ReadonlyArray.empty(), true] expect(result).toEqual(expected) }).pipe(runEffect)) @@ -112,9 +104,9 @@ describe("Options", () => { const args1 = ReadonlyArray.empty() const args2 = ReadonlyArray.make("--help") const args3 = ReadonlyArray.make("--help", "-v") - const result1 = yield* _(validation(options, args1, CliConfig.defaultConfig)) - const result2 = yield* _(validation(options, args2, CliConfig.defaultConfig)) - const result3 = yield* _(validation(options, args3, CliConfig.defaultConfig)) + const result1 = yield* _(process(options, args1, CliConfig.defaultConfig)) + const result2 = yield* _(process(options, args2, CliConfig.defaultConfig)) + const result3 = yield* _(process(options, args3, CliConfig.defaultConfig)) const expected1 = [ReadonlyArray.empty(), [false, false]] const expected2 = [ReadonlyArray.empty(), [true, false]] const expected3 = [ReadonlyArray.empty(), [true, true]] @@ -126,16 +118,16 @@ describe("Options", () => { it("validates a boolean option with negation", () => Effect.gen(function*(_) { const option = Options.boolean("verbose", { aliases: ["v"], negationNames: ["silent", "s"] }) - const result1 = yield* _(validation(option, [], CliConfig.defaultConfig)) - const result2 = yield* _(validation(option, ["--verbose"], CliConfig.defaultConfig)) - const result3 = yield* _(validation(option, ["-v"], CliConfig.defaultConfig)) - const result4 = yield* _(validation(option, ["--silent"], CliConfig.defaultConfig)) - const result5 = yield* _(validation(option, ["-s"], CliConfig.defaultConfig)) + const result1 = yield* _(process(option, [], CliConfig.defaultConfig)) + const result2 = yield* _(process(option, ["--verbose"], CliConfig.defaultConfig)) + const result3 = yield* _(process(option, ["-v"], CliConfig.defaultConfig)) + const result4 = yield* _(process(option, ["--silent"], CliConfig.defaultConfig)) + const result5 = yield* _(process(option, ["-s"], CliConfig.defaultConfig)) const result6 = yield* _( - Effect.flip(validation(option, ["--verbose", "--silent"], CliConfig.defaultConfig)) + Effect.flip(process(option, ["--verbose", "--silent"], CliConfig.defaultConfig)) ) const result7 = yield* _( - Effect.flip(validation(option, ["-v", "-s"], CliConfig.defaultConfig)) + Effect.flip(process(option, ["-v", "-s"], CliConfig.defaultConfig)) ) expect(result1).toEqual([[], false]) expect(result2).toEqual([[], true]) @@ -156,7 +148,7 @@ describe("Options", () => { Effect.gen(function*(_) { const option = Options.boolean("v", { negationNames: ["s"] }) const args = ReadonlyArray.make("-v", "-s") - const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( "Collision between two options detected - " + "you can only specify one of either: ['-v', '-s']" @@ -168,8 +160,8 @@ describe("Options", () => { 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)) + const result1 = yield* $(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* $(process(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], "cat"]) expect(result2).toEqual([[], "dog"]) }).pipe(runEffect)) @@ -187,8 +179,8 @@ describe("Options", () => { ]) 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)) + const result1 = yield* $(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* $(process(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], cat]) expect(result2).toEqual([[], dog]) }).pipe(runEffect)) @@ -196,40 +188,40 @@ describe("Options", () => { it("validates a text option", () => Effect.gen(function*(_) { const result = yield* _( - validation(firstName, ["--firstName", "John"], CliConfig.defaultConfig) + process(firstName, ["--firstName", "John"], CliConfig.defaultConfig) ) expect(result).toEqual([[], "John"]) }).pipe(runEffect)) it("validates a text option with an alternative format", () => Effect.gen(function*(_) { - const result = yield* _(validation(firstName, ["--firstName=John"], CliConfig.defaultConfig)) + const result = yield* _(process(firstName, ["--firstName=John"], CliConfig.defaultConfig)) expect(result).toEqual([[], "John"]) }).pipe(runEffect)) it("validates a text option with an alias", () => Effect.gen(function*(_) { - const result = yield* _(validation(firstName, ["-f", "John"], CliConfig.defaultConfig)) + const result = yield* _(process(firstName, ["-f", "John"], CliConfig.defaultConfig)) expect(result).toEqual([[], "John"]) }).pipe(runEffect)) it("validates an integer option", () => Effect.gen(function*(_) { - const result = yield* _(validation(age, ["--age", "100"], CliConfig.defaultConfig)) + const result = yield* _(process(age, ["--age", "100"], CliConfig.defaultConfig)) expect(result).toEqual([[], 100]) }).pipe(runEffect)) it("validates an option and returns the remainder", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstName", "John", "--lastName", "Doe") - const result = yield* _(validation(firstName, args, CliConfig.defaultConfig)) + const result = yield* _(process(firstName, args, CliConfig.defaultConfig)) expect(result).toEqual([["--lastName", "Doe"], "John"]) }).pipe(runEffect)) it("does not validate when no valid values are passed", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--lastName", "Doe") - const result = yield* _(Effect.either(validation(firstName, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.either(process(firstName, args, CliConfig.defaultConfig))) expect(result).toEqual(Either.left(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--firstName'" )))) @@ -238,7 +230,7 @@ describe("Options", () => { it("does not validate when an option is passed without a corresponding value", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstName") - const result = yield* _(Effect.either(validation(firstName, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.either(process(firstName, args, CliConfig.defaultConfig))) expect(result).toEqual(Either.left(ValidationError.missingValue(HelpDoc.p( "Expected a value following option: '--firstName'" )))) @@ -248,7 +240,7 @@ describe("Options", () => { Effect.gen(function*(_) { const option = Options.integer("t") const args = ReadonlyArray.make("-t", "abc") - const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p("'abc' is not a integer"))) }).pipe(runEffect)) @@ -256,7 +248,7 @@ describe("Options", () => { Effect.gen(function*(_) { const option = Options.withDefault(Options.integer("t"), 0) const args = ReadonlyArray.make("-t", "abc") - const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p("'abc' is not a integer"))) }).pipe(runEffect)) @@ -268,10 +260,10 @@ describe("Options", () => { const args2 = ReadonlyArray.make("-F", "John") const args3 = ReadonlyArray.make("--firstname", "John") const args4 = ReadonlyArray.make("-f", "John") - const result1 = yield* _(validation(option, args1, config)) - const result2 = yield* _(validation(option, args2, config)) - const result3 = yield* _(Effect.flip(validation(option, args3, config))) - const result4 = yield* _(Effect.flip(validation(option, args4, config))) + const result1 = yield* _(process(option, args1, config)) + const result2 = yield* _(process(option, args2, config)) + const result3 = yield* _(Effect.flip(process(option, args3, config))) + const result4 = yield* _(Effect.flip(process(option, args4, config))) expect(result1).toEqual([[], "John"]) expect(result2).toEqual([[], "John"]) expect(result3).toEqual(ValidationError.correctedFlag(HelpDoc.p( @@ -284,21 +276,21 @@ describe("Options", () => { it("validates an unsupplied optional option", () => Effect.gen(function*(_) { - const result = yield* _(validation(ageOptional, [], CliConfig.defaultConfig)) + const result = yield* _(process(ageOptional, [], CliConfig.defaultConfig)) expect(result).toEqual([[], Option.none()]) }).pipe(runEffect)) it("validates an unsupplied optional option with remainder", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--bar", "baz") - const result = yield* _(validation(ageOptional, args, CliConfig.defaultConfig)) + const result = yield* _(process(ageOptional, args, CliConfig.defaultConfig)) expect(result).toEqual([args, Option.none()]) }).pipe(runEffect)) it("validates a supplied optional option", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--age", "20") - const result = yield* _(validation(ageOptional, args, CliConfig.defaultConfig)) + const result = yield* _(process(ageOptional, args, CliConfig.defaultConfig)) expect(result).toEqual([[], Option.some(20)]) }).pipe(runEffect)) @@ -310,8 +302,8 @@ describe("Options", () => { }) const option2 = Options.all([Options.text("firstName"), Options.text("lastName")]) const args = ReadonlyArray.make("--firstName", "John", "--lastName", "Doe") - const result1 = yield* _(validation(option1, args, CliConfig.defaultConfig)) - const result2 = yield* _(validation(option2, args, CliConfig.defaultConfig)) + const result1 = yield* _(process(option1, args, CliConfig.defaultConfig)) + const result2 = yield* _(process(option2, args, CliConfig.defaultConfig)) expect(result1).toEqual([[], { firstName: "John", lastName: "Doe" }]) expect(result2).toEqual([[], ["John", "Doe"]]) }).pipe(runEffect)) @@ -319,7 +311,7 @@ describe("Options", () => { it("validate provides a suggestion if a provided option is close to a specified option", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstme", "Alice") - const result = yield* _(Effect.flip(validation(firstName, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(firstName, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.correctedFlag(HelpDoc.p( "The flag '--firstme' is not recognized. Did you mean '--firstName'?" ))) @@ -329,7 +321,7 @@ describe("Options", () => { Effect.gen(function*(_) { const option = firstName.pipe(Options.withDefault("Jack")) const args = ReadonlyArray.make("--firstme", "Alice") - const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( "The flag '--firstme' is not recognized. Did you mean '--firstName'?" ))) @@ -347,8 +339,8 @@ describe("Options", () => { ) const args1 = ReadonlyArray.make("--integer", "2") const args2 = ReadonlyArray.make("--string", "two") - const result1 = yield* _(validation(option, args1, CliConfig.defaultConfig)) - const result2 = yield* _(validation(option, args2, CliConfig.defaultConfig)) + const result1 = yield* _(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(process(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], Either.right(2)]) expect(result2).toEqual([[], Either.left("two")]) }).pipe(runEffect)) @@ -357,7 +349,7 @@ describe("Options", () => { Effect.gen(function*(_) { const option = Options.orElse(Options.text("string"), Options.integer("integer")) const args = ReadonlyArray.make("--integer", "2", "--string", "two") - const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( "Collision between two options detected - " + "you can only specify one of either: ['--string', '--integer']" @@ -367,7 +359,7 @@ describe("Options", () => { it("orElse - no options provided", () => Effect.gen(function*(_) { const option = Options.orElse(Options.text("string"), Options.integer("integer")) - const result = yield* _(Effect.flip(Options.validate(option, [], CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, [], CliConfig.defaultConfig))) const error = ValidationError.missingValue(HelpDoc.sequence( HelpDoc.p("Expected to find option: '--string'"), HelpDoc.p("Expected to find option: '--integer'") @@ -382,7 +374,7 @@ describe("Options", () => { Options.withDefault(0) ) const args = ReadonlyArray.make("--min", "abc") - const result = yield* _(Effect.flip(Options.validate(option, args, CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(option, args, CliConfig.defaultConfig))) const error = ValidationError.invalidValue(HelpDoc.sequence( HelpDoc.p("'abc' is not a integer"), HelpDoc.p("Expected to find option: '--max'") @@ -392,7 +384,7 @@ describe("Options", () => { it("keyValueMap - validates a missing option", () => Effect.gen(function*(_) { - const result = yield* _(Effect.flip(validation(defs, [], CliConfig.defaultConfig))) + const result = yield* _(Effect.flip(process(defs, [], CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--defs'" ))) @@ -401,21 +393,21 @@ describe("Options", () => { it("keyValueMap - validates repeated values", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("-d", "key1=v1", "-d", "key2=v2", "--verbose") - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) }).pipe(runEffect)) it("keyValueMap - validates different key/values", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--defs", "key1=v1", "key2=v2", "--verbose") - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) }).pipe(runEffect)) it("keyValueMap - validates different key/values with alias", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("-d", "key1=v1", "key2=v2", "--verbose") - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) }).pipe(runEffect)) @@ -432,7 +424,7 @@ describe("Options", () => { "arg2", "--verbose" ) - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([ ["arg1", "arg2", "--verbose"], HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) @@ -450,7 +442,7 @@ describe("Options", () => { "arg2", "--verbose" ) - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([ ["arg1", "arg2", "--verbose"], HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) @@ -470,19 +462,10 @@ describe("Options", () => { "arg2", "--verbose" ) - const result = yield* _(Effect.flip(validation(defs, args, CliConfig.defaultConfig))) - expect(result).toEqual(ValidationError.invalidArgument(HelpDoc.p( - "Expected a key/value pair but received 'key4='" - ))) - }).pipe(runEffect)) - - it("keyValueMap - validate should parse regardless of position", () => - Effect.gen(function*(_) { - const args = ["--defs", "key1=val1", "arg", "--defs", "key2=val2"] - const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) + const result = yield* _(process(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([ - ReadonlyArray.of("arg"), - HashMap.make(["key1", "val1"], ["key2", "val2"]) + ["key4=", "arg1", "arg2", "--verbose"], + HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) ]) }).pipe(runEffect)) @@ -491,8 +474,8 @@ describe("Options", () => { 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))) + const result1 = yield* _(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(process(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)) @@ -502,8 +485,8 @@ describe("Options", () => { 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))) + const result1 = yield* _(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(process(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'" @@ -515,8 +498,8 @@ describe("Options", () => { 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))) + const result1 = yield* _(process(option, args1, CliConfig.defaultConfig)) + const result2 = yield* _(Effect.flip(process(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'" @@ -530,10 +513,10 @@ describe("Options", () => { 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))) + const result1 = yield* _(Effect.flip(process(option, args1, CliConfig.defaultConfig))) + const result2 = yield* _(process(option, args2, CliConfig.defaultConfig)) + const result3 = yield* _(process(option, args3, CliConfig.defaultConfig)) + const result4 = yield* _(Effect.flip(process(option, args4, CliConfig.defaultConfig))) expect(result1).toEqual(ValidationError.invalidValue(HelpDoc.p( "Expected at least 2 value(s) for option: '--foo'" )))