| 1 | /** |
| 2 | * Command Implementation |
| 3 | * ====================== |
| 4 | * |
| 5 | * Internal implementation details for CLI commands. |
| 6 | * Public API is in ../Command.ts |
| 7 | */ |
| 8 | import * as Arr from "../../Array.ts" |
| 9 | import * as Context from "../../Context.ts" |
| 10 | import * as Effect from "../../Effect.ts" |
| 11 | import * as Effectable from "../../Effectable.ts" |
| 12 | import * as Option from "../../Option.ts" |
| 13 | import * as Predicate from "../../Predicate.ts" |
| 14 | import * as CliError from "../CliError.ts" |
| 15 | import type * as GlobalFlag from "../GlobalFlag.ts" |
| 16 | import type { ArgDoc, ExampleDoc, FlagDoc, HelpDoc, SubcommandGroupDoc } from "../HelpDoc.ts" |
| 17 | import * as Param from "../Param.ts" |
| 18 | import * as Primitive from "../Primitive.ts" |
| 19 | import { type ConfigInternal, emptyConfig, reconstructTree } from "./config.ts" |
| 20 | |
| 21 | /* ========================================================================== */ |
| 22 | /* Types */ |
| 23 | /* ========================================================================== */ |
| 24 | |
| 25 | import type { Command, CommandContext, Environment, ParsedTokens } from "../Command.ts" |
| 26 | |
| 27 | interface SubcommandGroup { |
| 28 | readonly group: string | undefined |
| 29 | readonly commands: Arr.NonEmptyReadonlyArray<Command<any, unknown, any, unknown, unknown>> |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Internal implementation interface with all the machinery. |
| 34 | * Use toImpl() to access from internal code. |
| 35 | */ |
| 36 | export interface CommandInternal<Name extends string, Input, E, R, ContextInput> |
| 37 | extends Command<Name, Input, ContextInput, E, R> |
| 38 | { |
| 39 | readonly config: ConfigInternal |
| 40 | readonly contextConfig: ConfigInternal |
| 41 | readonly service: Context.Key<CommandContext<Name>, ContextInput> |
| 42 | readonly annotations: Context.Context<never> |
| 43 | readonly globalFlags: ReadonlyArray<GlobalFlag.GlobalFlag<any>> |
| 44 | readonly parse: (input: ParsedTokens) => Effect.Effect<Input, CliError.CliError, Environment> |
| 45 | readonly parseContext: (input: ParsedTokens) => Effect.Effect<ContextInput, CliError.CliError, Environment> |
| 46 | readonly handle: ( |
| 47 | input: Input, |
| 48 | commandPath: ReadonlyArray<string> |
| 49 | ) => Effect.Effect<void, E | CliError.CliError, R | Environment> |
| 50 | readonly buildHelpDoc: (commandPath: ReadonlyArray<string>) => HelpDoc |
| 51 | } |
| 52 | |
| 53 | /* ========================================================================== */ |
| 54 | /* Type ID */ |
| 55 | /* ========================================================================== */ |
| 56 | |
| 57 | export const TypeId = "~effect/cli/Command" as const |
| 58 | |
| 59 | /* ========================================================================== */ |
| 60 | /* Casting */ |
| 61 | /* ========================================================================== */ |
| 62 | |
| 63 | /** |
| 64 | * Casts a Command to its internal implementation. |
| 65 | * For use by internal modules that need access to config, parse, handle, etc. |
| 66 | */ |
| 67 | export const toImpl = <Name extends string, Input, E, R, ContextInput = {}>( |
| 68 | self: Command<Name, Input, ContextInput, E, R> |
| 69 | ): CommandInternal<Name, Input, E, R, ContextInput> => self as CommandInternal<Name, Input, E, R, ContextInput> |
| 70 | |
| 71 | /* ========================================================================== */ |
| 72 | /* Proto */ |
| 73 | /* ========================================================================== */ |
| 74 | |
| 75 | export const Proto: Effect.Effect<any, never, any> = { |
| 76 | ...Effectable.Prototype<Command.Any>({ |
| 77 | label: "Command", |
| 78 | evaluate() { |
| 79 | return toImpl(this).service |
| 80 | } |
| 81 | }) |
| 82 | } |
| 83 | |
| 84 | /* ========================================================================== */ |
| 85 | /* Constructor */ |
| 86 | /* ========================================================================== */ |
| 87 | |
| 88 | /** |
| 89 | * Internal command constructor. Only accepts already-parsed ConfigInternal. |
| 90 | */ |
| 91 | export const makeCommand = <const Name extends string, Input, E, R, ContextInput = {}>(options: { |
| 92 | readonly name: Name |
| 93 | readonly config: ConfigInternal |
| 94 | readonly contextConfig?: ConfigInternal | undefined |
| 95 | readonly service?: Context.Key<CommandContext<Name>, ContextInput> | undefined |
| 96 | readonly annotations?: Context.Context<never> | undefined |
| 97 | readonly globalFlags?: ReadonlyArray<GlobalFlag.GlobalFlag<any>> | undefined |
| 98 | readonly description?: string | undefined |
| 99 | readonly shortDescription?: string | undefined |
| 100 | readonly alias?: string | undefined |
| 101 | readonly unlisted?: boolean | undefined |
| 102 | readonly examples?: ReadonlyArray<Command.Example> | undefined |
| 103 | readonly subcommands?: ReadonlyArray<SubcommandGroup> | undefined |
| 104 | readonly parse?: ((input: ParsedTokens) => Effect.Effect<Input, CliError.CliError, Environment>) | undefined |
| 105 | readonly parseContext?: |
| 106 | | ((input: ParsedTokens) => Effect.Effect<ContextInput, CliError.CliError, Environment>) |
| 107 | | undefined |
| 108 | readonly handle?: |
| 109 | | ((input: Input, commandPath: ReadonlyArray<string>) => Effect.Effect<void, E, R | Environment>) |
| 110 | | undefined |
| 111 | }): Command<Name, Input, ContextInput, E, R> => { |
| 112 | const config = options.config |
| 113 | const contextConfig = options.contextConfig ?? emptyConfig |
| 114 | const service = options.service ?? Context.Service<CommandContext<Name>, ContextInput>(`${TypeId}/${options.name}`) |
| 115 | const annotations = options.annotations ?? Context.empty() |
| 116 | const globalFlags = options.globalFlags ?? [] |
| 117 | const subcommands = options.subcommands ?? [] |
| 118 | |
| 119 | const handle = ( |
| 120 | input: Input, |
| 121 | commandPath: ReadonlyArray<string> |
| 122 | ): Effect.Effect<void, CliError.CliError | E, R | Environment> => |
| 123 | Predicate.isNotUndefined(options.handle) |
| 124 | ? options.handle(input, commandPath) |
| 125 | : Effect.fail(new CliError.ShowHelp({ commandPath, errors: [] })) |
| 126 | |
| 127 | const parse = options.parse ?? makeParser(config) as any |
| 128 | const parseContext = options.parseContext ?? makeParser(contextConfig, { allowLeftovers: true }) as any |
| 129 | |
| 130 | const buildHelpDoc = (commandPath: ReadonlyArray<string>): HelpDoc => { |
| 131 | const args: Array<ArgDoc> = [] |
| 132 | const flags: Array<FlagDoc> = [] |
| 133 | |
| 134 | for (const arg of config.arguments) { |
| 135 | const singles = Param.extractSingleParams(arg) |
| 136 | const metadata = Param.getParamMetadata(arg) |
| 137 | for (const single of singles) { |
| 138 | args.push({ |
| 139 | name: single.name, |
| 140 | type: single.typeName ?? Primitive.getTypeName(single.primitiveType), |
| 141 | description: single.description, |
| 142 | required: !metadata.isOptional && |
| 143 | (!metadata.isVariadic || Option.exists(metadata.variadicMin, (min) => min > 0)), |
| 144 | variadic: metadata.isVariadic |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | let usage = commandPath.length > 0 ? commandPath.join(" ") : options.name |
| 150 | // Only render `<subcommand>` in usage when at least one visible subcommand |
| 151 | // exists; an all-unlisted subcommand tree should look like a leaf command. |
| 152 | if (subcommands.some((group) => group.commands.some((c) => !c.unlisted))) { |
| 153 | usage += " <subcommand>" |
| 154 | } |
| 155 | usage += " [flags]" |
| 156 | for (const arg of args) { |
| 157 | const argName = arg.variadic ? `<${arg.name}...>` : `<${arg.name}>` |
| 158 | usage += ` ${arg.required ? argName : `[${argName}]`}` |
| 159 | } |
| 160 | |
| 161 | for (const option of config.flags) { |
| 162 | const singles = Param.extractSingleParams(option) |
| 163 | const metadata = Param.getParamMetadata(option) |
| 164 | for (const single of singles) { |
| 165 | // Hidden flags still parse on the command line but are omitted from |
| 166 | // generated --help output. |
| 167 | if (single.hidden) continue |
| 168 | flags.push(toFlagDoc(single, metadata)) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | const subcommandDocs: Array<SubcommandGroupDoc> = [] |
| 173 | |
| 174 | for (const group of subcommands) { |
| 175 | // Unlisted subcommands still parse on the command line but are omitted |
| 176 | // from --help. Drop the whole group when nothing visible remains so we |
| 177 | // don't render an empty heading. |
| 178 | const visible = group.commands.filter((c) => !c.unlisted) |
| 179 | if (visible.length === 0) continue |
| 180 | subcommandDocs.push({ |
| 181 | group: group.group, |
| 182 | commands: Arr.map(visible as unknown as Arr.NonEmptyReadonlyArray<Command.Any>, (subcommand) => ({ |
| 183 | name: subcommand.name, |
| 184 | alias: subcommand.alias, |
| 185 | shortDescription: subcommand.shortDescription, |
| 186 | description: subcommand.description ?? "" |
| 187 | })) |
| 188 | }) |
| 189 | } |
| 190 | |
| 191 | const examples: ReadonlyArray<ExampleDoc> = options.examples ?? [] |
| 192 | |
| 193 | return { |
| 194 | description: options.description ?? "", |
| 195 | usage, |
| 196 | flags, |
| 197 | annotations, |
| 198 | ...(args.length > 0 && { args }), |
| 199 | ...(subcommandDocs.length > 0 && { subcommands: subcommandDocs }), |
| 200 | ...(examples.length > 0 && { examples }) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | return Object.assign(Object.create(Proto), { |
| 205 | [TypeId]: TypeId, |
| 206 | name: options.name, |
| 207 | examples: options.examples ?? [], |
| 208 | annotations, |
| 209 | globalFlags, |
| 210 | subcommands, |
| 211 | unlisted: options.unlisted ?? false, |
| 212 | config, |
| 213 | contextConfig, |
| 214 | service, |
| 215 | parse, |
| 216 | parseContext, |
| 217 | handle, |
| 218 | buildHelpDoc, |
| 219 | ...(Predicate.isNotUndefined(options.description) |
| 220 | ? { description: options.description } |
| 221 | : {}), |
| 222 | ...(Predicate.isNotUndefined(options.shortDescription) |
| 223 | ? { shortDescription: options.shortDescription } |
| 224 | : {}), |
| 225 | ...(Predicate.isNotUndefined(options.alias) |
| 226 | ? { alias: options.alias } |
| 227 | : {}) |
| 228 | }) |
| 229 | } |
| 230 | |
| 231 | /* ========================================================================== */ |
| 232 | /* Helpers */ |
| 233 | /* ========================================================================== */ |
| 234 | |
| 235 | /** |
| 236 | * Converts a single flag param into a FlagDoc for help display. |
| 237 | * |
| 238 | * @internal |
| 239 | */ |
| 240 | export const toFlagDoc = ( |
| 241 | single: Param.Single<typeof Param.flagKind, unknown>, |
| 242 | metadata: ReturnType<typeof Param.getParamMetadata> |
| 243 | ): FlagDoc => { |
| 244 | const formattedAliases = single.aliases.map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`) |
| 245 | return { |
| 246 | name: single.name, |
| 247 | aliases: formattedAliases, |
| 248 | type: single.typeName ?? Primitive.getTypeName(single.primitiveType), |
| 249 | description: appendChoiceKeys(single.description, Primitive.getChoiceKeys(single.primitiveType)), |
| 250 | required: single.primitiveType._tag !== "Boolean" && !metadata.isOptional |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | const appendChoiceKeys = ( |
| 255 | description: Option.Option<string>, |
| 256 | choiceKeys: ReadonlyArray<string> | undefined |
| 257 | ): Option.Option<string> => { |
| 258 | if (choiceKeys === undefined || choiceKeys.length === 0) { |
| 259 | return description |
| 260 | } |
| 261 | const choiceSuffix = `(choices: ${choiceKeys.join(", ")})` |
| 262 | return Option.match(description, { |
| 263 | onNone: () => Option.some(choiceSuffix), |
| 264 | onSome: (value) => Option.some(`${value} ${choiceSuffix}`) |
| 265 | }) |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Creates a parser for a given config. Used as the default for both `parse` |
| 270 | * and `parseContext`, and also by `withSharedFlags` to avoid constructing a |
| 271 | * full throwaway command. |
| 272 | */ |
| 273 | export const makeParser = ( |
| 274 | cfg: ConfigInternal, |
| 275 | options?: { |
| 276 | readonly allowLeftovers?: boolean | undefined |
| 277 | } | undefined |
| 278 | ) => |
| 279 | Effect.fnUntraced(function*(input: ParsedTokens) { |
| 280 | const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } |
| 281 | const [remainingArguments, values] = yield* parseParams(parsedArgs, cfg.orderedParams) |
| 282 | if (options?.allowLeftovers !== true && remainingArguments.length > 0) { |
| 283 | return yield* new CliError.UnexpectedArgument({ arguments: remainingArguments }) |
| 284 | } |
| 285 | return reconstructTree(cfg.tree, values) |
| 286 | }) |
| 287 | |
| 288 | /** |
| 289 | * Parses param values from parsed command arguments into their typed |
| 290 | * representations. |
| 291 | */ |
| 292 | const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray<Param.Any>) => Effect.Effect< |
| 293 | readonly [remainingArguments: ReadonlyArray<string>, values: ReadonlyArray<unknown>], |
| 294 | CliError.CliError, |
| 295 | Environment |
| 296 | > = Effect.fnUntraced(function*(parsedArgs, params) { |
| 297 | const results: Array<unknown> = [] |
| 298 | let currentArguments = parsedArgs.arguments |
| 299 | |
| 300 | for (const option of params) { |
| 301 | const [remainingArguments, parsed] = yield* option.parse({ |
| 302 | flags: parsedArgs.flags, |
| 303 | arguments: currentArguments |
| 304 | }) |
| 305 | results.push(parsed) |
| 306 | currentArguments = remainingArguments |
| 307 | } |
| 308 | |
| 309 | return [currentArguments, results] as const |
| 310 | }) |
| 311 | |
| 312 | /** |
| 313 | * Checks that inherited parent context flags do not reuse names declared by |
| 314 | * child command flags. |
| 315 | * |
| 316 | * When `contextConfig` is supplied, it is used as the inherited flag set; |
| 317 | * otherwise the parent's current context config is checked. |
| 318 | */ |
| 319 | export const checkForDuplicateFlags = <Name extends string, Input, ContextInput, E, R>( |
| 320 | parent: Command<Name, Input, ContextInput, E, R>, |
| 321 | subcommands: ReadonlyArray<Command.Any>, |
| 322 | options?: { |
| 323 | readonly contextConfig?: ConfigInternal | undefined |
| 324 | } | undefined |
| 325 | ): void => { |
| 326 | const parentImpl = toImpl(parent) |
| 327 | const parentOptionNames = new Set<string>() |
| 328 | |
| 329 | const extractNames = (flags: ReadonlyArray<Param.Any>): void => { |
| 330 | for (const option of flags) { |
| 331 | const singles = Param.extractSingleParams(option) |
| 332 | for (const single of singles) { |
| 333 | parentOptionNames.add(single.name) |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | extractNames((options?.contextConfig ?? parentImpl.contextConfig).flags) |
| 339 | |
| 340 | for (const subcommand of subcommands) { |
| 341 | const subImpl = toImpl(subcommand as any) |
| 342 | for (const option of subImpl.config.flags) { |
| 343 | const singles = Param.extractSingleParams(option) |
| 344 | for (const single of singles) { |
| 345 | if (parentOptionNames.has(single.name)) { |
| 346 | throw new CliError.DuplicateOption({ |
| 347 | option: single.name, |
| 348 | parentCommand: parent.name, |
| 349 | childCommand: subcommand.name |
| 350 | }) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | } |