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 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/minigit.ts b/examples/minigit.ts index a936721..3579a7a 100644 --- a/examples/minigit.ts +++ b/examples/minigit.ts @@ -1,67 +1,71 @@ import * as Args from "@effect/cli/Args" -import * as CliApp from "@effect/cli/CliApp" 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" 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 = Command.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 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 = Command.make("add", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}, ({ verbose }) => + Effect.gen(function*(_) { + const configs = yield* _(configsString) + yield* _(Console.log(`Running 'minigit add' with '--verbose ${verbose}'${configs}`)) + })) + // 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] - }) -)) + 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])) @@ -69,56 +73,12 @@ const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitCl // Application // ============================================================================= -const cliApp = CliApp.make({ +const run = Command.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 new file mode 100644 index 0000000..644ab48 --- /dev/null +++ b/examples/naval-fate.ts @@ -0,0 +1,121 @@ +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" +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" }).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 } + +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 shipCommand = Command.make("ship", { + verbose: Options.boolean("verbose") +}) + +const newShipCommand = Command.make("new", { + name: nameArg +}, ({ name }) => + Effect.gen(function*(_) { + const { verbose } = yield* _(shipCommand) + yield* _(createShip(name)) + yield* _(Console.log(`Created ship: '${name}'`)) + if (verbose) { + yield* _(Console.log(`Verbose mode enabled`)) + } + })) + +const moveShipCommand = Command.make("move", { + ...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 = Command.make( + "shoot", + { ...coordinatesArg }, + ({ x, y }) => + Effect.gen(function*(_) { + yield* _(shoot(x, y)) + yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + }) +) + +const mineCommand = Command.make("mine") + +const setMineCommand = Command.make("set", { + ...coordinatesArg, + moored: mooredOption +}, ({ moored, x, y }) => + Effect.gen(function*(_) { + yield* _(setMine(x, y)) + yield* _( + Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) + ) + })) + +const removeMineCommand = Command.make("remove", { + ...coordinatesArg +}, ({ x, y }) => + Effect.gen(function*(_) { + yield* _(removeMine(x, y)) + yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + })) + +const run = Command.make("naval_fate").pipe( + Command.withDescription("An implementation of the Naval Fate CLI application."), + Command.withSubcommands([ + shipCommand.pipe(Command.withSubcommands([ + newShipCommand, + moveShipCommand, + shootShipCommand + ])), + mineCommand.pipe(Command.withSubcommands([ + setMineCommand, + removeMineCommand + ])) + ]), + Command.run({ + name: "Naval Fate", + version: "1.0.0" + }) +) + +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) +) + +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..167d799 --- /dev/null +++ b/examples/naval-fate/domain.ts @@ -0,0 +1,80 @@ +import * as Schema from "@effect/schema/Schema" +import * as Data from "effect/Data" + +/** + * 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 +}> { + 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 + * exist. + */ +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 + * already occupied by another ship. + */ +export class CoordinatesOccupiedError extends Data.TaggedError("CoordinatesOccupiedError")<{ + 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.number, + y: Schema.number, + status: Schema.literal("sailing", "destroyed") +}) { + 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.number, + y: Schema.number +}) { + static readonly create = (x: number, y: number) => new Mine({ x, y }) + + hasCoordinates(x: number, y: number): boolean { + return this.x === x && this.y === y + } +} 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/examples/prompt.ts b/examples/prompt.ts index f1d95b6..ddb62d7 100644 --- a/examples/prompt.ts +++ b/examples/prompt.ts @@ -1,4 +1,3 @@ -import * as CliApp from "@effect/cli/CliApp" import * as Command from "@effect/cli/Command" import * as Prompt from "@effect/cli/Prompt" import * as NodeContext from "@effect/platform-node/NodeContext" @@ -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 ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 271d8dc..70b4768 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,8 +771,8 @@ 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] @@ -789,8 +789,8 @@ 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] @@ -807,8 +807,8 @@ 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] @@ -825,8 +825,8 @@ 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] @@ -843,8 +843,8 @@ 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] @@ -861,8 +861,8 @@ 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] @@ -879,8 +879,8 @@ 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] @@ -897,8 +897,8 @@ 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] @@ -915,8 +915,8 @@ 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] @@ -933,8 +933,8 @@ 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] @@ -951,8 +951,8 @@ 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] @@ -969,8 +969,8 @@ 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] @@ -987,8 +987,8 @@ 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] @@ -1005,8 +1005,8 @@ 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] @@ -1023,8 +1023,8 @@ 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] @@ -1041,8 +1041,8 @@ 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] @@ -1059,8 +1059,8 @@ 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] @@ -1077,8 +1077,8 @@ 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] @@ -1095,8 +1095,8 @@ 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] @@ -1113,8 +1113,8 @@ 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] @@ -1131,8 +1131,8 @@ 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] @@ -1149,8 +1149,8 @@ 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] @@ -2899,34 +2899,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 + '@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 dev: true /escalade@3.1.1: @@ -6103,7 +6103,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/Args.ts b/src/Args.ts index ed81d5b..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" @@ -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 /** @@ -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/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..1e83799 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -2,19 +2,21 @@ * @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 { 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 { CliApp } from "./CliApp.js" import type { CliConfig } from "./CliConfig.js" -import type { CommandDirective } from "./CommandDirective.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" @@ -22,268 +24,323 @@ 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 Context { + readonly _: unique symbol + readonly name: Name } /** * @since 1.0.0 * @category models */ - export interface ConstructorConfig { - readonly options?: Options - readonly args?: Args + export interface ConfigBase { + readonly [key: string]: + | Args + | Options + | ReadonlyArray | Options | ConfigBase> + | ConfigBase } /** * @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 + * @category constructors */ -export const getBashCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getBashCompletions +export const fromDescriptor: { + (): ( + command: Descriptor.Command + ) => Command -/** - * @since 1.0.0 - * @category combinators - */ -export const getFishCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getFishCompletions + ( + handler: (_: A) => Effect + ): (command: Descriptor.Command) => Command -/** - * @since 1.0.0 - * @category combinators - */ -export const getZshCompletions: ( - self: Command, - programName: string -) => Effect> = InternalCommand.getZshCompletions + ( + descriptor: Descriptor.Command + ): Command -/** - * @since 1.0.0 - * @category combinators - */ -export const getNames: (self: Command) => HashSet = InternalCommand.getNames + ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect + ): Command +} = Internal.fromDescriptor /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const getSubcommands: (self: Command) => HashMap> = - InternalCommand.getSubcommands +export const getHelp: (self: Command) => HelpDoc = + Internal.getHelp /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const getUsage: (self: Command) => Usage = InternalCommand.getUsage +export const getNames: ( + self: Command +) => HashSet = Internal.getNames /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const map: { - (f: (a: A) => B): (self: Command) => Command - (self: Command, f: (a: A) => B): Command -} = InternalCommand.map +export const getBashCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getBashCompletions /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const mapOrFail: { - (f: (a: A) => Either): (self: Command) => Command - (self: Command, f: (a: A) => Either): Command -} = InternalCommand.mapOrFail +export const getFishCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getFishCompletions /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const orElse: { - (that: Command): (self: Command) => Command - (self: Command, that: Command): Command -} = InternalCommand.orElse +export const getZshCompletions: ( + self: Command, + programName: string +) => Effect> = Internal.getZshCompletions /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const orElseEither: { - (that: Command): (self: Command) => Command> - (self: Command, that: Command): Command> -} = InternalCommand.orElseEither +export const getSubcommands: ( + self: Command +) => HashMap> = Internal.getSubcommands /** * @since 1.0.0 - * @category combinators + * @category accessors */ -export const parse: { - ( - args: ReadonlyArray, - config: CliConfig - ): ( - self: Command - ) => Effect> - ( - self: Command, - args: ReadonlyArray, - config: CliConfig - ): Effect> -} = InternalCommand.parse +export const getUsage: (self: Command) => Usage = + Internal.getUsage /** * @since 1.0.0 * @category constructors */ -export const prompt: ( - name: Name, - prompt: Prompt -) => Command<{ readonly name: Name; readonly value: A }> = InternalCommand.prompt +export const make: { + (name: Name): Command< + Name, + never, + never, + {} + > + + ( + name: Name, + config: Config + ): Command< + Name, + never, + never, + Types.Simplify> + > + + ( + name: Name, + config: Config, + handler: (_: Types.Simplify>) => Effect + ): Command< + Name, + R, + E, + Types.Simplify> + > +} = Internal.make /** * @since 1.0.0 * @category constructors */ -export const make: ( +export const prompt: ( 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>, Command.Context>, + 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>, Command.Context>, + E | Effect.Error>, + Descriptor.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option> } + > > > -} = InternalCommand.withSubcommands +} = Internal.withSubcommands /** * @since 1.0.0 - * @category combinators + * @category accessors */ export const wizard: { ( + rootCommand: string, config: CliConfig - ): ( - self: Command - ) => Effect> - ( - self: Command, + ): ( + self: Command + ) => Effect> + ( + self: Command, + rootCommand: string, config: CliConfig - ): Effect> -} = InternalCommand.wizard + ): Effect> +} = Internal.wizard + +/** + * @since 1.0.0 + * @category conversions + */ +export const run: { + ( + 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..99fb9a2 --- /dev/null +++ b/src/CommandDescriptor.ts @@ -0,0 +1,271 @@ +/** + * @since 1.0.0 + */ +import type { FileSystem } from "@effect/platform/FileSystem" +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" +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 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: { + < + const Subcommands extends readonly [ + readonly [id: unknown, command: Command], + ...Array]> + ] + >( + subcommands: [...Subcommands] + ): ( + self: Command + ) => Command< + Command.ComputeParsedType< + A & Readonly<{ subcommand: Option> }> + > + > + < + A, + const 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: { + ( + rootCommand: string, + config: CliConfig + ): ( + self: Command + ) => Effect> + ( + self: Command, + rootCommand: string, + config: CliConfig + ): Effect> +} = Internal.wizard 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/Options.ts b/src/Options.ts index 6cc1889..54d01d7 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" @@ -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 @@ -445,8 +447,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 /** @@ -474,9 +476,11 @@ export const withPseudoName: { export const wizard: { ( config: CliConfig - ): (self: Options) => Effect> + ): ( + self: Options + ) => Effect> ( self: Options, config: CliConfig - ): Effect> + ): Effect> } = InternalOptions.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/ValidationError.ts b/src/ValidationError.ts index 43fad2a..a3e6dee 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 "./CommandDescriptor.js" import type { HelpDoc } from "./HelpDoc.js" +import * as InternalCommand from "./internal/commandDescriptor.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/index.ts b/src/index.ts index 933b4a8..3688e12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,11 @@ export * as CliConfig from "./CliConfig.js" */ export * as Command from "./Command.js" +/** + * @since 1.0.0 + */ +export * as CommandDescriptor from "./CommandDescriptor.js" + /** * @since 1.0.0 */ diff --git a/src/internal/args.ts b/src/internal/args.ts index 5e60aae..79269ed 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 */ @@ -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)) @@ -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 @@ -734,11 +734,9 @@ const withDescriptionInternal = (self: Instruction, description: string): Args.A } } -const wizardHeader = InternalHelpDoc.p("ARGS 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/builtInOptions.ts b/src/internal/builtInOptions.ts index df4d550..0c7685e 100644 --- a/src/internal/builtInOptions.ts +++ b/src/internal/builtInOptions.ts @@ -1,8 +1,8 @@ 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 * 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..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" @@ -9,12 +10,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" @@ -84,7 +85,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( @@ -178,41 +186,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) - return Console.log(InternalHelpDoc.toAnsiText(help)).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": { @@ -260,13 +280,15 @@ 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}`)) + InternalHelpDoc.p(InternalSpan.concat( + InternalSpan.text(" "), + InternalSpan.highlight(params, Color.cyan) + )) ]) } 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..12d8dc9 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,1349 +1,438 @@ -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Terminal from "@effect/platform/Terminal" -import * as Console from "effect/Console" +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 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 { 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" -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 { CliConfig } from "../CliConfig.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 type { Usage } from "../Usage.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 + 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 isStandard = (self: Instruction): self is Standard => self._tag === "Standard" + return { + args, + options, + tree: parse(config) + } +} -/** @internal */ -export const isGetUserInput = (self: Instruction): self is GetUserInput => - self._tag === "GetUserInput" +const reconstructConfigTree = ( + tree: Command.Command.ParsedConfigTree, + args: ReadonlyArray, + options: ReadonlyArray +): Record => { + const output: Record = {} -/** @internal */ -export const isMap = (self: Instruction): self is Map => self._tag === "Map" + for (const key in tree) { + output[key] = nodeValue(tree[key]) + } -/** @internal */ -export const isOrElse = (self: Instruction): self is OrElse => self._tag === "OrElse" + return output -/** @internal */ -export const isSubcommands = (self: Instruction): self is Subcommands => self._tag === "Subcommands" + 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) + } + } +} -// ============================================================================= -// Constructors -// ============================================================================= +const Prototype = { + ...Effectable.CommitPrototype, + [TypeId]: TypeId, + commit(this: Command.Command) { + return this.tag + }, + pipe() { + return pipeArguments(this, arguments) + } +} -const defaultConstructorConfig = { - options: InternalOptions.none, - args: InternalArgs.none +const registeredDescriptors = globalValue( + "@effect/cli/Command/registeredDescriptors", + () => new WeakMap, Descriptor.Command>() +) + +const getDescriptor = (self: Command.Command) => + registeredDescriptors.get(self.tag) ?? self.descriptor + +const makeProto = ( + descriptor: Descriptor.Command, + handler: (_: A) => Effect.Effect, + tag?: Context.Tag +): Command.Command => { + const self = Object.create(Prototype) + self.descriptor = descriptor + self.handler = handler + self.tag = tag ?? Context.Tag() + return self } /** @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 +export const fromDescriptor = dual< + { + (): ( + 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 self: Command.Command = makeProto( + descriptor, + handler ?? + ((_) => Effect.failSync(() => ValidationError.helpRequested(getDescriptor(self)))) + ) + return self as any + } +) + +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 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 -} +export const make: { + (name: Name): Command.Command< + Name, + never, + never, + {} + > -// ============================================================================= -// Combinators -// ============================================================================= + ( + name: Name, + config: Config + ): Command.Command< + Name, + never, + never, + Types.Simplify> + > + + ( + 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 getHelp = (self: Command.Command): HelpDoc.HelpDoc => - getHelpInternal(self as Instruction) +export const getHelp = ( + self: Command.Command +): HelpDoc => InternalDescriptor.getHelp(self.descriptor) /** @internal */ -export const getNames = (self: Command.Command): HashSet.HashSet => - getNamesInternal(self as Instruction) +export const getNames = ( + self: Command.Command +): HashSet => InternalDescriptor.getNames(self.descriptor) /** @internal */ -export const getBashCompletions = ( - self: Command.Command, +export const getBashCompletions = ( + self: Command.Command, programName: string ): Effect.Effect> => - getBashCompletionsInternal(self as Instruction, programName) + InternalDescriptor.getBashCompletions(self.descriptor, programName) /** @internal */ -export const getFishCompletions = ( - self: Command.Command, +export const getFishCompletions = ( + self: Command.Command, programName: string ): Effect.Effect> => - getFishCompletionsInternal(self as Instruction, programName) + InternalDescriptor.getFishCompletions(self.descriptor, programName) /** @internal */ -export const getZshCompletions = ( - self: Command.Command, +export const getZshCompletions = ( + self: Command.Command, programName: string ): Effect.Effect> => - getZshCompletionsInternal(self as Instruction, programName) + InternalDescriptor.getZshCompletions(self.descriptor, programName) /** @internal */ -export const getSubcommands = ( - self: Command.Command -): HashMap.HashMap> => getSubcommandsInternal(self as Instruction) +export const getSubcommands = ( + self: Command.Command +): HashMap> => + InternalDescriptor.getSubcommands(self.descriptor) /** @internal */ -export const getUsage = (self: Command.Command): Usage.Usage => - getUsageInternal(self as Instruction) +export const getUsage = ( + self: Command.Command +): Usage => InternalDescriptor.getUsage(self.descriptor) + +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 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)))) - -/** @internal */ -export const mapOrFail = 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)) +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) - if (ReadonlyArray.isNonEmptyReadonlyArray(tail)) { - const child = ReadonlyArray.reduce( - ReadonlyArray.tailNonEmpty(tail), - orElse(head, ReadonlyArray.headNonEmpty(tail)), - orElse + const command = InternalDescriptor.withSubcommands( + self.descriptor, + ReadonlyArray.map(subcommands, (_) => [_.tag, _.descriptor]) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + new Map, Command.Command>(), + (handlers, subcommand) => { + handlers.set(subcommand.tag, subcommand) + registeredDescriptors.set(subcommand.tag, subcommand.descriptor) + return handlers + } + ) + function handler( + args: { + readonly name: string + readonly subcommand: Option.Option, value: unknown]> + } + ) { + if (args.subcommand._tag === "Some") { + const [tag, value] = args.subcommand.value + const subcommand = handlers.get(tag)! + return Effect.provideService( + subcommand.handler(value), + self.tag, + args as any ) - op.child = child - return op } - op.child = head - return op + return self.handler(args as any) } - throw new Error("[BUG]: Command.subcommands - received empty list of subcommands") + return makeProto(command as any, handler, self.tag) as any }) /** @internal */ export const wizard = dual< - (config: CliConfig.CliConfig) => (self: Command.Command) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + ( + rootCommand: string, + config: CliConfig + ) => ( + self: Command.Command + ) => Effect.Effect< + FileSystem | Terminal, + QuitException | ValidationError.ValidationError, ReadonlyArray >, - (self: Command.Command, config: CliConfig.CliConfig) => Effect.Effect< - FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + ( + self: Command.Command, + rootCommand: string, + config: CliConfig + ) => Effect.Effect< + FileSystem | Terminal, + QuitException | 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.prepend(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) - } - } -} +>(3, (self, rootCommand, config) => InternalDescriptor.wizard(self.descriptor, rootCommand, config)) -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.succeed( - 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( - 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)) - }) - ) - 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) - } - 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 -> => { - 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) - ))) - } - case "GetUserInput": { - return Effect.succeed(ReadonlyArray.empty()) - } - case "Map": { - return wizardInternal(self.command as Instruction, config) - } - 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))) - ) - ) - } - 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)) - ) - } - } -} - -// ============================================================================= -// 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) - 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( - // 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)) - )) +/** @internal */ +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 }) - -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) - } - } -} + registeredDescriptors.set(self.tag, 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..175dd06 --- /dev/null +++ b/src/internal/commandDescriptor.ts @@ -0,0 +1,1480 @@ +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" +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 Ref from "effect/Ref" +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< + < + const 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, + const 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< + ( + rootCommand: string, + config: CliConfig.CliConfig + ) => (self: Descriptor.Command) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + Terminal.QuitException | ValidationError.ValidationError, + ReadonlyArray + >, + ( + self: Descriptor.Command, + rootCommand: string, + config: CliConfig.CliConfig + ) => Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + Terminal.QuitException | ValidationError.ValidationError, + ReadonlyArray + > +>(3, (self, rootCommand, config) => wizardInternal(self as Instruction, rootCommand, 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.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}'`) + 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.processCommandLine(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.catchTag("QuitException", (e) => Effect.die(e)), + 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 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, + Terminal.QuitException | ValidationError.ValidationError, + ReadonlyArray +> => { + const loop = ( + self: WizardCommandSequence, + commandLineRef: Ref.Ref> + ): Effect.Effect< + FileSystem.FileSystem | Terminal.Terminal, + Terminal.QuitException | ValidationError.ValidationError, + ReadonlyArray + > => { + switch (self._tag) { + case "SingleCommandWizard": { + return Effect.gen(function*(_) { + const logCurrentCommand = Ref.get(commandLineRef).pipe(Effect.flatMap((commandLine) => { + const currentCommand = InternalHelpDoc.p(pipe( + InternalSpan.strong(InternalSpan.highlight("COMMAND:", Color.cyan)), + InternalSpan.concat(InternalSpan.space), + InternalSpan.concat(InternalSpan.highlight( + ReadonlyArray.join(commandLine, " "), + Color.magenta + )) + )) + return Console.log(InternalHelpDoc.toAnsiText(currentCommand)) + })) + 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 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 the command has args, run the wizard for them + if (!InternalArgs.isEmpty(self.command.args as InternalArgs.Instruction)) { + 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: [title, value] as const + }) + 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.tap(([name]) => Ref.update(commandLineRef, ReadonlyArray.append(name))), + Effect.zipLeft(Console.log()), + Effect.flatMap(([, nextSequence]) => loop(nextSequence, commandLineRef)) + ) + } + case "SubcommandWizard": { + return loop(self.parent, commandLineRef).pipe( + Effect.zipRight(loop(self.child, commandLineRef)) + ) + } + } + } + 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 + +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 +} 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 4228b34..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, @@ -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 */ @@ -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)) @@ -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 @@ -987,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": { @@ -1350,11 +1176,9 @@ const parseInternal = ( } } -const wizardHeader = InternalHelpDoc.p("OPTIONS WIZARD") - const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.Effect< FileSystem.FileSystem | Terminal.Terminal, - ValidationError.ValidationError, + Terminal.QuitException | ValidationError.ValidationError, ReadonlyArray > => { switch (self._tag) { @@ -1362,31 +1186,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 parseCommandLine(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": { @@ -1402,8 +1224,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 }) @@ -1417,28 +1238,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 - }).pipe(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) => @@ -1453,22 +1270,22 @@ 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, - 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.zipLeft(Console.log()), Effect.flatMap((useFallback) => useFallback ? Effect.succeed(ReadonlyArray.empty()) @@ -1483,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. @@ -1600,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 = ( @@ -1722,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 7f2adef..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`) }) @@ -504,9 +510,10 @@ 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}`)) @@ -539,7 +546,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}`)) } @@ -553,7 +560,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() + }) } } } 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) { 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..fc02f20 100644 --- a/test/Command.test.ts +++ b/test/Command.test.ts @@ -1,480 +1,96 @@ -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 CommandDirective from "@effect/cli/CommandDirective" -import * as HelpDoc from "@effect/cli/HelpDoc" -import * as Options from "@effect/cli/Options" -import * as Grep from "@effect/cli/test/utils/grep" -import * as Tail from "@effect/cli/test/utils/tail" -import * as WordCount from "@effect/cli/test/utils/wc" -import * as ValidationError from "@effect/cli/ValidationError" -import * as FileSystem from "@effect/platform-node/FileSystem" -import * as Terminal from "@effect/platform-node/Terminal" -import * as Doc from "@effect/printer/Doc" -import * as Render from "@effect/printer/Render" -import { Effect, Option, ReadonlyArray, String } from "effect" -import * as Layer from "effect/Layer" -import { describe, expect, it } from "vitest" +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 MainLive = Layer.merge(FileSystem.layer, Terminal.layer) - -const runEffect = ( - self: Effect.Effect -): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) - -describe("Command", () => { - describe("Standard Commands", () => { - it("should validate a command with options followed by arguments", () => - 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 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)) - expect(result2).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected2)) - }).pipe(runEffect)) - - it("should provide auto-correct suggestions for misspelled options", () => - Effect.gen(function*(_) { - const args1 = ReadonlyArray.make("grep", "--afte", "2", "--before", "3", "fooBar") - 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)) - ) - const result2 = yield* _( - Effect.flip(Command.parse(Grep.command, args2, CliConfig.defaultConfig)) - ) - const result3 = yield* _( - Effect.flip(Command.parse(Grep.command, args3, CliConfig.defaultConfig)) - ) - expect(result1).toEqual(ValidationError.correctedFlag(HelpDoc.p( - "The flag '--afte' is not recognized. Did you mean '--after'?" - ))) - expect(result2).toEqual(ValidationError.correctedFlag(HelpDoc.p( - "The flag '--efore' is not recognized. Did you mean '--before'?" - ))) - expect(result3).toEqual(ValidationError.correctedFlag(HelpDoc.p( - "The flag '--afte' is not recognized. Did you mean '--after'?" - ))) - }).pipe(runEffect)) - - it("should return an error if an option is missing", () => - 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)) - ) - expect(result).toEqual(ValidationError.missingValue(HelpDoc.sequence( - HelpDoc.p("Expected to find option: '--after'"), - HelpDoc.p("Expected to find option: '--before'") - ))) - }).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 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)) - }).pipe(runEffect)) - - 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 expected = { name: "wc", options: [false, false, false, true], args: ["-clk"] } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - - it("should not alter '-'", () => - Effect.gen(function*(_) { - const args = ReadonlyArray.make("wc", "-") - const result = yield* _(Command.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)) - }) - - 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") - ])) - - 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 expected = { name: "git", options: true, args: void 0, subcommand: Option.none() } - expect(result).toEqual(CommandDirective.userDefined([], expected)) - }).pipe(runEffect)) - - 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 expected = { - name: "git", - options: false, - args: void 0, - subcommand: Option.some({ name: "remote", options: void 0, args: void 0 }) - } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - - 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 expected = { - name: "git", - options: false, - args: void 0, - subcommand: Option.some({ name: "remote", options: void 0, args: void 0 }) - } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.of("-v"), expected)) - }).pipe(runEffect)) - - 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 expected = { - name: "git", - options: false, - args: void 0, - subcommand: Option.some({ name: "log", options: void 0, args: void 0 }) - } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - - 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))) - expect(result).toEqual(ValidationError.commandMismatch(HelpDoc.p( - "Invalid subcommand for git - use one of 'log', 'remote'" - ))) - }).pipe(runEffect)) - }) - - describe("Subcommands with Options and Arguments", () => { - const options = Options.all([ - Options.boolean("i"), - Options.text("empty").pipe(Options.withDefault("drop")) - ]) - - const args = Args.all([Args.text(), Args.text()]) - - const git = Command.make("git").pipe(Command.withSubcommands([ - Command.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 expected = { - name: "git", - options: void 0, - args: void 0, - subcommand: Option.some({ - name: "rebase", - options: [true, "drop"], - args: ["upstream", "branch"] - }) - } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - - it("should parse a subcommand with required and optional options and arguments", () => - Effect.gen(function*(_) { - const args = ReadonlyArray.make( - "git", - "rebase", - "-i", - "--empty", - "ask", - "upstream", - "branch" - ) - const result = yield* _(Command.parse(git, args, CliConfig.defaultConfig)) - const expected = { - name: "git", - options: void 0, - args: void 0, - subcommand: Option.some({ - 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() }) - ])) - ])) - - 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 expected = { - name: "command", - options: void 0, - args: void 0, - subcommand: Option.some({ - name: "sub", - options: void 0, - args: void 0, - subcommand: Option.some({ - name: "subsub", - options: true, - args: "text" - }) - }) - } - expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) - }).pipe(runEffect)) - }) - - 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 args = ReadonlyArray.of("tldr") - const result = yield* _(Command.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) - }).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 expected = HelpDoc.sequence(HelpDoc.h1("DESCRIPTION"), HelpDoc.p("this is some help")) - expect(Command.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 child1 = Command.make("child1").pipe( - Command.withSubcommands([child2]), - Command.withDescription("help 1") - ) - const parent = Command.make("parent").pipe(Command.withSubcommands([child1])) - const result = Render.prettyDefault( - Doc.unAnnotate(HelpDoc.toAnsiDoc(Command.getHelp(parent))) - ) - expect(result).toBe(String.stripMargin( - `|COMMANDS - | - | - child1 help 1 - | - | - child2 child1 help 2 - | - | - child3 child1 help 3 - |` - )) - }) - }) - - describe("Built-In Options Processing", () => { - const command = Command.make("command", { options: Options.text("a") }) - const params1 = ReadonlyArray.make("command", "--help") - const params2 = ReadonlyArray.make("command", "-h") - const params3 = ReadonlyArray.make("command", "--wizard") - const params4 = ReadonlyArray.make("command", "--completions", "sh") - const params5 = ReadonlyArray.make("command", "-a", "--help") - const params6 = ReadonlyArray.make("command", "--help", "--wizard", "-b") - const params7 = ReadonlyArray.make("command", "-hdf", "--help") - const params8 = ReadonlyArray.make("command", "-af", "asdgf", "--wizard") +const git = Command.make("git", { + verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) +}) - const directiveType = (directive: CommandDirective.CommandDirective): string => { - if (CommandDirective.isBuiltIn(directive)) { - if (BuiltInOptions.isShowHelp(directive.option)) { - return "help" - } - if (BuiltInOptions.isShowWizard(directive.option)) { - return "wizard" - } - if (BuiltInOptions.isShowCompletions(directive.option)) { - return "completions" - } - } - return "user" +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`)) + } + })) - it("should trigger built-in options if they are alone", () => - Effect.gen(function*(_) { - const result1 = yield* _( - Command.parse(command, params1, CliConfig.defaultConfig), - Effect.map(directiveType) - ) - const result2 = yield* _( - Command.parse(command, params2, CliConfig.defaultConfig), - Effect.map(directiveType) - ) - const result3 = yield* _( - Command.parse(command, params3, CliConfig.defaultConfig), - Effect.map(directiveType) - ) - const result4 = yield* _( - Command.parse(command, params4, CliConfig.defaultConfig), - Effect.map(directiveType) - ) - expect(result1).toBe("help") - expect(result2).toBe("help") - expect(result3).toBe("wizard") - expect(result4).toBe("completions") - }).pipe(runEffect)) - - it("should not trigger help if an option matches", () => - Effect.gen(function*(_) { - const result = yield* _( - Command.parse(command, params5, CliConfig.defaultConfig), - Effect.map(directiveType) - ) - expect(result).toBe("user") - }).pipe(runEffect)) - - it("should trigger help even if not alone", () => - Effect.gen(function*(_) { - const config = CliConfig.make({ finalCheckBuiltIn: true }) - const result1 = yield* _( - Command.parse(command, params6, config), - Effect.map(directiveType) - ) - const result2 = yield* _( - Command.parse(command, params7, config), - Effect.map(directiveType) - ) - expect(result1).toBe("help") - expect(result2).toBe("help") - }).pipe(runEffect)) - - it("should trigger wizard even if not alone", () => - Effect.gen(function*(_) { - const config = CliConfig.make({ finalCheckBuiltIn: true }) - const result = yield* _( - Command.parse(command, params8, config), - Effect.map(directiveType) - ) - expect(result).toBe("wizard") - }).pipe(runEffect)) - }) - - describe("End of Command Options Symbol", () => { - const command = Command.make("cmd", { - options: Options.all([ - Options.optional(Options.text("something")), - Options.boolean("verbose").pipe(Options.withAlias("v")) - ]), - args: 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 expected1 = { - name: "cmd", - options: [Option.some("abc"), true], - args: ReadonlyArray.of("something") - } - const expected2 = { - name: "cmd", - options: [Option.none(), true], - args: ReadonlyArray.make("--something", "abc", "something") - } - const expected3 = { - name: "cmd", - options: [Option.none(), false], - args: ReadonlyArray.make("-v", "--something", "abc", "something") - } - expect(result1).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected1)) - expect(result2).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected2)) - expect(result3).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected3)) - }).pipe(runEffect)) +const run = git.pipe( + Command.withSubcommands([clone, add]), + Command.run({ + name: "git", + version: "1.0.0" }) +) - describe("Completions", () => { - const command = Command.make("forge").pipe(Command.withSubcommands([ - Command.make("cache", { - options: Options.boolean("verbose").pipe( - Options.withDescription("Output in verbose mode") - ) - }).pipe( - Command.withDescription("The cache command does cache things"), - Command.withSubcommands([ - Command.make("clean"), - Command.make("ls") +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 "Adding repo" ]) - ) - ])) - - it("should create completions for the bash shell", () => - Effect.gen(function*(_) { - const result = yield* _(Command.getBashCompletions(command, "forge")) - yield* _( - Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/bash-completions")) - ) - }).pipe(runEffect)) - - it("should create completions for the zsh shell", () => - Effect.gen(function*(_) { - const result = yield* _(Command.getZshCompletions(command, "forge")) - yield* _( - Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/zsh-completions")) - ) - }).pipe(runEffect)) + }).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 "Cloning repo" + ]) + }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + }) +}) - it("should create completions for the fish shell", () => - Effect.gen(function*(_) { - const result = yield* _(Command.getFishCompletions(command, "forge")) - yield* _( - Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/fish-completions")) - ) - }).pipe(runEffect)) +// -- + +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) diff --git a/test/CommandDescriptor.test.ts b/test/CommandDescriptor.test.ts new file mode 100644 index 0000000..e708e31 --- /dev/null +++ b/test/CommandDescriptor.test.ts @@ -0,0 +1,482 @@ +import * as Args from "@effect/cli/Args" +import * as BuiltInOptions from "@effect/cli/BuiltInOptions" +import * as CliConfig from "@effect/cli/CliConfig" +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" +import * as Grep from "@effect/cli/test/utils/grep" +import * as Tail from "@effect/cli/test/utils/tail" +import * as WordCount from "@effect/cli/test/utils/wc" +import * as ValidationError from "@effect/cli/ValidationError" +import * as FileSystem from "@effect/platform-node/FileSystem" +import * as Terminal from "@effect/platform-node/Terminal" +import * as Doc from "@effect/printer/Doc" +import * as Render from "@effect/printer/Render" +import { Effect, Option, ReadonlyArray, String } from "effect" +import * as Layer from "effect/Layer" +import { describe, expect, it } from "vitest" + +const MainLive = Layer.merge(FileSystem.layer, Terminal.layer) + +const runEffect = ( + self: Effect.Effect +): Promise => + Effect.provide(self, MainLive).pipe( + Effect.runPromise + ) + +describe("Command", () => { + describe("Standard Commands", () => { + it("should validate a command with options followed by arguments", () => + 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* _(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)) + expect(result2).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected2)) + }).pipe(runEffect)) + + it("should provide auto-correct suggestions for misspelled options", () => + Effect.gen(function*(_) { + const args1 = ReadonlyArray.make("grep", "--afte", "2", "--before", "3", "fooBar") + 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(Descriptor.parse(Grep.command, args1, CliConfig.defaultConfig)) + ) + const result2 = yield* _( + Effect.flip(Descriptor.parse(Grep.command, args2, CliConfig.defaultConfig)) + ) + const result3 = yield* _( + 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'?" + ))) + expect(result2).toEqual(ValidationError.correctedFlag(HelpDoc.p( + "The flag '--efore' is not recognized. Did you mean '--before'?" + ))) + expect(result3).toEqual(ValidationError.correctedFlag(HelpDoc.p( + "The flag '--afte' is not recognized. Did you mean '--after'?" + ))) + }).pipe(runEffect)) + + it("should return an error if an option is missing", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("grep", "--a", "2", "--before", "3", "fooBar") + const result = yield* _( + Effect.flip(Descriptor.parse(Grep.command, args, CliConfig.defaultConfig)) + ) + expect(result).toEqual(ValidationError.missingValue(HelpDoc.sequence( + HelpDoc.p("Expected to find option: '--after'"), + HelpDoc.p("Expected to find option: '--before'") + ))) + }).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* _( + 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)) + }).pipe(runEffect)) + + it("should not uncluster wrong clusters", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("wc", "-clk") + 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)) + + it("should not alter '-'", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("wc", "-") + 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)) + }) + + describe("Subcommands without Options or Arguments", () => { + const options = Options.boolean("verbose").pipe(Options.withAlias("v")) + + 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* _(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)) + + it("should match the first subcommand without any surplus arguments", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("git", "remote") + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) + const expected = { + name: "git", + options: false, + 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)) + + it("matches the first subcommand with a surplus option", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("git", "remote", "-v") + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) + const expected = { + name: "git", + options: false, + 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)) + + it("matches the second subcommand without any surplus arguments", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("git", "log") + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) + const expected = { + name: "git", + options: false, + 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)) + + it("should return an error message for an unknown subcommand", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make("git", "abc") + 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'" + ))) + }).pipe(runEffect)) + }) + + describe("Subcommands with Options and Arguments", () => { + const options = Options.all([ + Options.boolean("i"), + Options.text("empty").pipe(Options.withDefault("drop")) + ]) + + const args = Args.all([Args.text(), Args.text()]) + + 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* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) + const expected = { + name: "git", + options: void 0, + args: void 0, + subcommand: Option.some(["rebase", { + name: "rebase", + options: [true, "drop"], + args: ["upstream", "branch"] + }]) + } + expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) + }).pipe(runEffect)) + + it("should parse a subcommand with required and optional options and arguments", () => + Effect.gen(function*(_) { + const args = ReadonlyArray.make( + "git", + "rebase", + "-i", + "--empty", + "ask", + "upstream", + "branch" + ) + const result = yield* _(Descriptor.parse(git, args, CliConfig.defaultConfig)) + const expected = { + name: "git", + options: void 0, + args: void 0, + 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 = 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* _(Descriptor.parse(command, args, CliConfig.defaultConfig)) + const expected = { + name: "command", + options: void 0, + args: void 0, + subcommand: Option.some(["sub", { + name: "sub", + options: void 0, + args: void 0, + subcommand: Option.some(["subsub", { + name: "subsub", + options: true, + args: "text" + }]) + }]) + } + expect(result).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected)) + }).pipe(runEffect)) + }) + + describe("Help Documentation", () => { + it("should allow adding help documentation to a command", () => + Effect.gen(function*(_) { + const cmd = Descriptor.make("tldr").pipe(Descriptor.withDescription("this is some help")) + const args = ReadonlyArray.of("tldr") + 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(Descriptor.getHelp(cmd)).toEqual(expectedDoc) + }).pipe(runEffect)) + + it("should allow adding help documentation to subcommands", () => { + 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(Descriptor.getHelp(cmd)).not.toEqual(expected) + }) + + it("should correctly display help documentation for a command", () => { + 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 parent = Descriptor.make("parent").pipe( + Descriptor.withSubcommands([["child1", child1]]) + ) + const result = Render.prettyDefault( + Doc.unAnnotate(HelpDoc.toAnsiDoc(Descriptor.getHelp(parent))) + ) + expect(result).toBe(String.stripMargin( + `|COMMANDS + | + | - child1 help 1 + | + | - child1 child2 help 2 + |` + )) + }) + }) + + describe("Built-In Options Processing", () => { + 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") + const params4 = ReadonlyArray.make("command", "--completions", "sh") + const params5 = ReadonlyArray.make("command", "-a", "--help") + const params6 = ReadonlyArray.make("command", "--help", "--wizard", "-b") + const params7 = ReadonlyArray.make("command", "-hdf", "--help") + const params8 = ReadonlyArray.make("command", "-af", "asdgf", "--wizard") + + const directiveType = (directive: CommandDirective.CommandDirective): string => { + if (CommandDirective.isBuiltIn(directive)) { + if (BuiltInOptions.isShowHelp(directive.option)) { + return "help" + } + if (BuiltInOptions.isShowWizard(directive.option)) { + return "wizard" + } + if (BuiltInOptions.isShowCompletions(directive.option)) { + return "completions" + } + } + return "user" + } + + it("should trigger built-in options if they are alone", () => + Effect.gen(function*(_) { + const result1 = yield* _( + Descriptor.parse(command, params1, CliConfig.defaultConfig), + Effect.map(directiveType) + ) + const result2 = yield* _( + Descriptor.parse(command, params2, CliConfig.defaultConfig), + Effect.map(directiveType) + ) + const result3 = yield* _( + Descriptor.parse(command, params3, CliConfig.defaultConfig), + Effect.map(directiveType) + ) + const result4 = yield* _( + Descriptor.parse(command, params4, CliConfig.defaultConfig), + Effect.map(directiveType) + ) + expect(result1).toBe("help") + expect(result2).toBe("help") + expect(result3).toBe("wizard") + expect(result4).toBe("completions") + }).pipe(runEffect)) + + it("should not trigger help if an option matches", () => + Effect.gen(function*(_) { + const result = yield* _( + Descriptor.parse(command, params5, CliConfig.defaultConfig), + Effect.map(directiveType) + ) + expect(result).toBe("user") + }).pipe(runEffect)) + + it("should trigger help even if not alone", () => + Effect.gen(function*(_) { + const config = CliConfig.make({ finalCheckBuiltIn: true }) + const result1 = yield* _( + Descriptor.parse(command, params6, config), + Effect.map(directiveType) + ) + const result2 = yield* _( + Descriptor.parse(command, params7, config), + Effect.map(directiveType) + ) + expect(result1).toBe("help") + expect(result2).toBe("help") + }).pipe(runEffect)) + + it("should trigger wizard even if not alone", () => + Effect.gen(function*(_) { + const config = CliConfig.make({ finalCheckBuiltIn: true }) + const result = yield* _( + Descriptor.parse(command, params8, config), + Effect.map(directiveType) + ) + expect(result).toBe("wizard") + }).pipe(runEffect)) + }) + + describe("End of Command Options Symbol", () => { + const command = Descriptor.make( + "cmd", + Options.all([ + Options.optional(Options.text("something")), + Options.boolean("verbose").pipe(Options.withAlias("v")) + ]), + 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* _(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], + args: ReadonlyArray.of("something") + } + const expected2 = { + name: "cmd", + options: [Option.none(), true], + args: ReadonlyArray.make("--something", "abc", "something") + } + const expected3 = { + name: "cmd", + options: [Option.none(), false], + args: ReadonlyArray.make("-v", "--something", "abc", "something") + } + expect(result1).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected1)) + expect(result2).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected2)) + expect(result3).toEqual(CommandDirective.userDefined(ReadonlyArray.empty(), expected3)) + }).pipe(runEffect)) + }) + + describe("Completions", () => { + 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")] + ]) + ) + ] + ])) + + it("should create completions for the bash shell", () => + Effect.gen(function*(_) { + const result = yield* _(Descriptor.getBashCompletions(command, "forge")) + yield* _( + Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/bash-completions")) + ) + }).pipe(runEffect)) + + it("should create completions for the zsh shell", () => + Effect.gen(function*(_) { + const result = yield* _(Descriptor.getZshCompletions(command, "forge")) + yield* _( + Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/zsh-completions")) + ) + }).pipe(runEffect)) + + it("should create completions for the fish shell", () => + Effect.gen(function*(_) { + const result = yield* _(Descriptor.getFishCompletions(command, "forge")) + yield* _( + Effect.promise(() => expect(result).toMatchFileSnapshot("./snapshots/fish-completions")) + ) + }).pipe(runEffect)) + }) +}) 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'" ))) 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) 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": [