1/**
2 * Command Implementation
3 * ======================
4 *
5 * Internal implementation details for CLI commands.
6 * Public API is in ../Command.ts
7 */
8import * as Arr from "../../Array.ts"
9import * as Context from "../../Context.ts"
10import * as Effect from "../../Effect.ts"
11import * as Effectable from "../../Effectable.ts"
12import * as Option from "../../Option.ts"
13import * as Predicate from "../../Predicate.ts"
14import * as CliError from "../CliError.ts"
15import type * as GlobalFlag from "../GlobalFlag.ts"
16import type { ArgDoc, ExampleDoc, FlagDoc, HelpDoc, SubcommandGroupDoc } from "../HelpDoc.ts"
17import * as Param from "../Param.ts"
18import * as Primitive from "../Primitive.ts"
19import { type ConfigInternal, emptyConfig, reconstructTree } from "./config.ts"
20
21/* ========================================================================== */
22/* Types */
23/* ========================================================================== */
24
25import type { Command, CommandContext, Environment, ParsedTokens } from "../Command.ts"
26
27interface 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 */
36export 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
57export 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 */
67export 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
75export 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 */
91export 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 */
240export 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
254const 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 */
273export 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 */
292const 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 */
319export 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}