1/**
2 * Formats CLI help and errors as text.
3 *
4 * This module turns help documents, CLI errors, grouped errors, and version
5 * information into strings. It does not write those strings to the terminal
6 * itself. It includes the `Formatter` interface, the formatter service, a layer
7 * for custom formatters, and the default formatter with configurable color
8 * support.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13
14import * as Context from "../Context.ts"
15import * as Layer from "../Layer.ts"
16import * as Option from "../Option.ts"
17import type * as CliError from "./CliError.ts"
18import type { HelpDoc } from "./HelpDoc.ts"
19
20/**
21 * Defines the service interface for formatting CLI output including help, errors, and version info.
22 * This allows customization of output formatting, including color support.
23 *
24 * **Example** (Customizing CLI output formatting)
25 *
26 * ```ts import.meta.vitest
27 * import { Effect } from "effect"
28 * import { CliOutput } from "effect/cli"
29 *
30 * // Create a custom formatter implementation
31 * const customFormatter: CliOutput.Formatter = {
32 * formatHelpDoc: (doc) => `Custom Help: ${doc.usage}`,
33 * formatCliError: (error) => `Error: ${error.message}`,
34 * formatError: (error) => `[ERROR] ${error.message}`,
35 * formatVersion: (name, version) => `${name} (${version})`,
36 * formatErrors: (errors) => errors.map((error) => error.message).join("\\n")
37 * }
38 *
39 * // Use the custom formatter in a program
40 * const program = Effect.gen(function*() {
41 * const formatter = yield* CliOutput.Formatter
42 * return formatter.formatVersion("myapp", "1.0.0")
43 * }).pipe(
44 * Effect.provide(CliOutput.layer(customFormatter))
45 * )
46 *
47 * await Effect.runPromise(program) // => "myapp (1.0.0)"
48 * ```
49 *
50 * @stability unstable
51 * @category models
52 * @since 4.0.0
53 */
54export interface Formatter {
55 /**
56 * Formats a HelpDoc structure into a readable string format.
57 *
58 * **Example** (Formatting help documents)
59 *
60 * ```ts import.meta.vitest
61 * import { Option as O } from "effect"
62 * import { CliOutput } from "effect/cli"
63 * import type { HelpDoc } from "effect/cli"
64 *
65 * const helpDoc: HelpDoc = {
66 * usage: "myapp [options] <file>",
67 * description: "Process files with various options",
68 * flags: [
69 * {
70 * name: "verbose",
71 * aliases: ["-v"],
72 * type: "boolean",
73 * description: O.some("Enable verbose output"),
74 * required: false
75 * }
76 * ],
77 * args: [
78 * {
79 * name: "file",
80 * type: "string",
81 * description: O.some("Input file to process"),
82 * required: true,
83 * variadic: false
84 * }
85 * ]
86 * }
87 *
88 * const formatter = CliOutput.defaultFormatter()
89 * const helpText = formatter.formatHelpDoc(helpDoc)
90 * const sectionsPresent = [helpText.includes("DESCRIPTION"), helpText.includes("FLAGS")] // => [true, true]
91 * ```
92 *
93 * @stability unstable
94 * @since 4.0.0
95 */
96 readonly formatHelpDoc: (doc: HelpDoc) => string
97
98 /**
99 * Formats a CLI error for display. Default implementation mirrors the error message.
100 *
101 * **Example** (Formatting CLI errors)
102 *
103 * ```ts import.meta.vitest
104 * import { Data } from "effect"
105 * import { CliOutput } from "effect/cli"
106 *
107 * class InvalidOption extends Data.TaggedError("InvalidOption")<{
108 * readonly message: string
109 * }> {}
110 *
111 * const formatter = CliOutput.defaultFormatter()
112 * const error = new InvalidOption({ message: "Unknown flag '--invalid'" })
113 * const errorMessage = formatter.formatCliError(error)
114 * errorMessage // => "Unknown flag '--invalid'"
115 * ```
116 *
117 * @stability unstable
118 * @since 4.0.0
119 */
120 readonly formatCliError: (error: CliError.CliError) => string
121
122 /**
123 * Formats an error section with proper styling and color reset.
124 *
125 * **Example** (Formatting error sections)
126 *
127 * ```ts import.meta.vitest
128 * import { Data } from "effect"
129 * import { CliOutput } from "effect/cli"
130 *
131 * class ValidationError extends Data.TaggedError("ValidationError")<{
132 * readonly message: string
133 * }> {}
134 *
135 * const colorFormatter = CliOutput.defaultFormatter({ colors: true })
136 * const noColorFormatter = CliOutput.defaultFormatter({ colors: false })
137 *
138 * const error = new ValidationError({ message: "Value must be positive" })
139 *
140 * const coloredError = colorFormatter.formatError(error)
141 * coloredError.includes("\u001b[31mERROR") // => true
142 *
143 * const plainError = noColorFormatter.formatError(error)
144 * plainError // => "\nERROR\n Value must be positive"
145 * ```
146 *
147 * @stability unstable
148 * @since 4.0.0
149 */
150 readonly formatError: (error: CliError.CliError) => string
151
152 /**
153 * Formats version output for display.
154 *
155 * **Example** (Formatting version output)
156 *
157 * ```ts import.meta.vitest
158 * import { CliOutput } from "effect/cli"
159 *
160 * const colorFormatter = CliOutput.defaultFormatter({ colors: true })
161 * const noColorFormatter = CliOutput.defaultFormatter({ colors: false })
162 *
163 * const appName = "my-awesome-tool"
164 * const version = "1.2.3"
165 *
166 * const coloredVersion = colorFormatter.formatVersion(appName, version)
167 * coloredVersion // => "\u001b[1mmy-awesome-tool\u001b[0m \u001b[2mv\u001b[0m\u001b[1m1.2.3\u001b[0m"
168 *
169 * const plainVersion = noColorFormatter.formatVersion(appName, version)
170 * plainVersion // => "my-awesome-tool v1.2.3"
171 * ```
172 *
173 * @stability unstable
174 * @since 4.0.0
175 */
176 readonly formatVersion: (name: string, version: string) => string
177
178 /**
179 * Formats multiple CLI errors for display, grouping by error type.
180 *
181 * **Example** (Formatting grouped errors)
182 *
183 * ```ts import.meta.vitest
184 * import { CliError, CliOutput } from "effect/cli"
185 *
186 * const formatter = CliOutput.defaultFormatter({ colors: false })
187 *
188 * const errors = [
189 * new CliError.UnrecognizedOption({
190 * option: "--foo",
191 * suggestions: ["--force"]
192 * }),
193 * new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] }),
194 * new CliError.MissingOption({ option: "--required" })
195 * ]
196 *
197 * const output = formatter.formatErrors(errors)
198 * const optionsPresent = [output.includes("--foo"), output.includes("--required")] // => [true, true]
199 * ```
200 *
201 * @stability unstable
202 * @since 4.0.0
203 */
204 readonly formatErrors: (errors: ReadonlyArray<CliError.CliError>) => string
205}
206
207/**
208 * Service reference for the CLI output formatter. Provides a default implementation
209 * that can be overridden for custom formatting or testing.
210 *
211 * **Example** (Accessing the output formatter)
212 *
213 * ```ts import.meta.vitest
214 * import { Effect } from "effect"
215 * import { CliOutput } from "effect/cli"
216 *
217 * // Access the formatter service
218 * const program = Effect.gen(function*() {
219 * const formatter = yield* CliOutput.Formatter
220 *
221 * // Format version information
222 * return formatter.formatVersion("my-cli", "2.1.0")
223 * })
224 *
225 * // Run with default formatter
226 * await Effect.runPromise(program.pipe(
227 * Effect.provide(CliOutput.layer(CliOutput.defaultFormatter({ colors: false })))
228 * )) // => "my-cli v2.1.0"
229 * ```
230 *
231 * @stability unstable
232 * @category services
233 * @since 4.0.0
234 */
235export const Formatter: Context.Reference<Formatter> = Context.Reference(
236 "effect/cli/CliOutput",
237 { defaultValue: () => defaultFormatter() }
238)
239
240/**
241 * Creates a Layer that provides a custom Formatter implementation.
242 *
243 * **Example** (Providing a custom formatter)
244 *
245 * ```ts import.meta.vitest
246 * import { Effect } from "effect"
247 * import { CliOutput } from "effect/cli"
248 *
249 * // Create a custom formatter without colors
250 * const noColorFormatter = CliOutput.defaultFormatter({ colors: false })
251 * const NoColorLayer = CliOutput.layer(noColorFormatter)
252 *
253 * // Create a program that uses the custom formatter
254 * const program = Effect.gen(function*() {
255 * const formatter = yield* CliOutput.Formatter
256 * return formatter.formatVersion("my-cli", "1.0.0")
257 * }).pipe(
258 * Effect.provide(NoColorLayer)
259 * )
260 *
261 * await Effect.runPromise(program) // => "my-cli v1.0.0"
262 * ```
263 *
264 * @stability unstable
265 * @category layers
266 * @since 4.0.0
267 */
268export const layer = (formatter: Formatter): Layer.Layer<never> => Layer.succeed(Formatter)(formatter)
269
270const escapeControlCharacters = (text: string): string =>
271 // oxlint-disable-next-line no-control-regex
272 text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, (character) =>
273 `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`)
274
275/**
276 * Creates a default formatter with configurable options.
277 *
278 * **Example** (Creating default formatters)
279 *
280 * ```ts import.meta.vitest
281 * import { CliError, CliOutput } from "effect/cli"
282 *
283 * // Create a formatter without colors for tests or CI environments
284 * const noColorFormatter = CliOutput.defaultFormatter({ colors: false })
285 *
286 * // Create a formatter with colors forced on
287 * const colorFormatter = CliOutput.defaultFormatter({ colors: true })
288 *
289 * // Auto-detect colors based on terminal support (default behavior)
290 * const autoFormatter = CliOutput.defaultFormatter()
291 *
292 * const error = new CliError.InvalidValue({
293 * option: "foo",
294 * value: "bar",
295 * expected: "baz",
296 * kind: "flag"
297 * })
298 *
299 * noColorFormatter.formatError(error).includes("Invalid value") // => true
300 * colorFormatter.formatVersion("my-tool", "1.2.3").includes("my-tool") // => true
301 * autoFormatter.formatVersion("my-tool", "1.2.3").includes("1.2.3") // => true
302 * ```
303 *
304 * @stability unstable
305 * @category constructors
306 * @since 4.0.0
307 */
308export const defaultFormatter = (options?: { colors?: boolean }): Formatter => {
309 const globalProcess = (globalThis as any).process
310 const hasProcess = typeof globalProcess === "object" && globalProcess !== null
311
312 const useColor = options?.colors !== undefined
313 ? options.colors
314 // Auto-detect based on environment
315 : (hasProcess &&
316 typeof globalProcess.stdout === "object" &&
317 globalProcess.stdout !== null &&
318 globalProcess.stdout.isTTY === true &&
319 !globalProcess.env?.NO_COLOR)
320
321 // Color palette using ANSI escape codes
322 const colors = useColor
323 ? {
324 bold: (text: string): string => `\x1b[1m${text}\x1b[0m`,
325 dim: (text: string): string => `\x1b[2m${text}\x1b[0m`,
326 cyan: (text: string): string => `\x1b[36m${text}\x1b[0m`,
327 green: (text: string): string => `\x1b[32m${text}\x1b[0m`,
328 blue: (text: string): string => `\x1b[34m${text}\x1b[0m`,
329 yellow: (text: string): string => `\x1b[33m${text}\x1b[0m`,
330 magenta: (text: string): string => `\x1b[35m${text}\x1b[0m`
331 }
332 : {
333 bold: (text: string): string => text,
334 dim: (text: string): string => text,
335 cyan: (text: string): string => text,
336 green: (text: string): string => text,
337 blue: (text: string): string => text,
338 yellow: (text: string): string => text,
339 magenta: (text: string): string => text
340 }
341
342 const reset = useColor ? "\x1b[0m" : ""
343 const red = useColor ? "\x1b[31m" : ""
344 const bold = useColor ? "\x1b[1m" : ""
345
346 return {
347 formatHelpDoc: (doc: HelpDoc): string => formatHelpDocImpl(doc, colors),
348 formatCliError: (error): string => escapeControlCharacters(error.message),
349 formatError: (error): string => {
350 return `\n${bold}${red}ERROR${reset}\n ${escapeControlCharacters(error.message)}${reset}`
351 },
352 formatErrors: (errors): string => {
353 if (errors.length === 0) return ""
354 if (errors.length === 1) {
355 return `\n${bold}${red}ERROR${reset}\n ${escapeControlCharacters(errors[0].message)}${reset}`
356 }
357
358 // Group errors by _tag
359 const grouped = new Map<string, Array<CliError.CliError>>()
360 for (const error of errors) {
361 const tag = (error as any)._tag ?? "Error"
362 const group = grouped.get(tag) ?? []
363 group.push(error)
364 grouped.set(tag, group)
365 }
366
367 const sections: Array<string> = []
368 sections.push(`\n${bold}${red}ERRORS${reset}`)
369
370 for (const [, group] of grouped) {
371 for (const error of group) {
372 sections.push(` ${escapeControlCharacters(error.message)}${reset}`)
373 }
374 }
375
376 return sections.join("\n")
377 },
378 formatVersion: (name: string, version: string): string =>
379 `${colors.bold(name)} ${colors.dim("v")}${colors.bold(version)}`
380 }
381}
382
383/**
384 * Strips ANSI escape codes from a string to calculate visual width.
385 * @internal
386 */
387const stripAnsi = (text: string): string => {
388 // oxlint-disable-next-line no-control-regex
389 return text.replace(/\u001B\[[0-9;]*m/g, "")
390}
391
392/**
393 * Gets the terminal display width of a string (excluding ANSI codes).
394 * @internal
395 */
396const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
397
398const zeroWidthCodePoints = /[\p{Mark}\p{Control}\p{Default_Ignorable_Code_Point}]/gu
399const emojiPresentation = /\p{Emoji_Presentation}/u
400
401const isFullWidthCodePoint = (codePoint: number): boolean => {
402 if (codePoint < 0x1100) return false
403
404 return codePoint <= 0x115f ||
405 codePoint === 0x2329 ||
406 codePoint === 0x232a ||
407 (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
408 (codePoint >= 0x3040 && codePoint <= 0xa4cf) ||
409 (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
410 (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
411 (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
412 (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
413 (codePoint >= 0xff00 && codePoint <= 0xff60) ||
414 (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
415 (codePoint >= 0x1b000 && codePoint <= 0x1b2ff) ||
416 (codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
417 (codePoint >= 0x20000 && codePoint <= 0x3fffd)
418}
419
420const graphemeWidth = (grapheme: string): number => {
421 const visible = grapheme.replace(zeroWidthCodePoints, "")
422 if (visible.length === 0) return 0
423 if (emojiPresentation.test(grapheme) || grapheme.includes("\uFE0F")) return 2
424 return isFullWidthCodePoint(visible.codePointAt(0)!) ? 2 : 1
425}
426
427const visualLength = (text: string): number => {
428 let length = 0
429 for (const { segment } of graphemeSegmenter.segment(stripAnsi(text))) {
430 length += graphemeWidth(segment)
431 }
432 return length
433}
434
435/**
436 * Helper function to pad strings to a specified width.
437 * @internal
438 */
439const pad = (s: string, width: number) => {
440 const actualLength = visualLength(s)
441 const padding = Math.max(0, width - actualLength)
442 return s + " ".repeat(padding)
443}
444
445/**
446 * Interface for table rows with left and right columns.
447 * @internal
448 */
449interface Row {
450 left: string
451 right: string
452}
453
454/**
455 * Renders a table with aligned columns.
456 * @internal
457 */
458const renderTable = (rows: ReadonlyArray<Row>, widthCap?: number) => {
459 const maxColumn = Math.max(...rows.map((r) => visualLength(r.left))) + 4
460 const col = widthCap === undefined ? maxColumn : Math.min(maxColumn, widthCap)
461 return rows.map(({ left, right }) => ` ${pad(left, Math.max(col, visualLength(left) + 1))}${right}`).join("\n")
462}
463
464const formatSubcommandName = (name: string, alias: string | undefined): string => alias ? `${name}, ${alias}` : name
465
466/**
467 * Color functions interface for help formatting.
468 * @internal
469 */
470interface ColorFunctions {
471 readonly bold: (text: string) => string
472 readonly dim: (text: string) => string
473 readonly cyan: (text: string) => string
474 readonly green: (text: string) => string
475 readonly blue: (text: string) => string
476 readonly yellow: (text: string) => string
477 readonly magenta: (text: string) => string
478}
479
480/**
481 * Internal implementation of help formatting that accepts configurable color functions.
482 * @internal
483 */
484const formatHelpDocImpl = (doc: HelpDoc, colors: ColorFunctions): string => {
485 const sections: Array<string> = []
486
487 // Description section
488 if (doc.description) {
489 sections.push(colors.bold("DESCRIPTION"))
490 sections.push(` ${doc.description}`)
491 sections.push("")
492 }
493
494 // Usage section
495 sections.push(colors.bold("USAGE"))
496 sections.push(` ${colors.cyan(doc.usage)}`)
497 sections.push("")
498
499 // Arguments section
500 if (doc.args && doc.args.length > 0) {
501 sections.push(colors.bold("ARGUMENTS"))
502
503 const argRows: Array<Row> = doc.args.map((arg) => {
504 let name = arg.name
505 if (arg.variadic) {
506 name += "..."
507 }
508
509 const coloredName = colors.green(name)
510 const coloredType = colors.dim(arg.type)
511 const nameType = `${coloredName} ${coloredType}`
512
513 const optionalSuffix = arg.required ? "" : colors.dim(" (optional)")
514 const description = Option.getOrElse(arg.description, () => "") + optionalSuffix
515
516 return {
517 left: nameType,
518 right: description
519 }
520 })
521
522 sections.push(renderTable(argRows, 25))
523 sections.push("")
524 }
525
526 // Flags section
527 if (doc.flags.length > 0) {
528 sections.push(colors.bold("FLAGS"))
529
530 const flagRows: Array<Row> = doc.flags.map((flag) => {
531 const names: Array<string> = []
532
533 // Add main name with -- prefix first
534 names.push(colors.green(`--${flag.name}`))
535
536 // Add aliases after (like -f) to match expected ordering
537 for (const alias of flag.aliases) {
538 names.push(colors.green(alias))
539 }
540
541 const namesPart = names.join(", ")
542 const typePart = flag.type !== "boolean" ? ` ${colors.dim(flag.type)}` : ""
543
544 return {
545 left: namesPart + typePart,
546 right: Option.getOrElse(flag.description, () => "")
547 }
548 })
549
550 sections.push(renderTable(flagRows))
551 sections.push("")
552 }
553
554 // Global Flags section
555 if (doc.globalFlags && doc.globalFlags.length > 0) {
556 sections.push(colors.bold("GLOBAL FLAGS"))
557
558 const globalFlagRows: Array<Row> = doc.globalFlags.map((flag) => {
559 const names: Array<string> = []
560
561 // Add main name with -- prefix first
562 names.push(colors.green(`--${flag.name}`))
563
564 // Add aliases after (like -f) to match expected ordering
565 for (const alias of flag.aliases) {
566 names.push(colors.green(alias))
567 }
568
569 const namesPart = names.join(", ")
570 const typePart = flag.type !== "boolean" ? ` ${colors.dim(flag.type)}` : ""
571
572 return {
573 left: namesPart + typePart,
574 right: Option.getOrElse(flag.description, () => "")
575 }
576 })
577
578 sections.push(renderTable(globalFlagRows))
579 sections.push("")
580 }
581
582 // Subcommands section
583 if (doc.subcommands && doc.subcommands.length > 0) {
584 const ungrouped = doc.subcommands.find((group) => group.group === undefined)
585
586 if (ungrouped) {
587 sections.push(colors.bold("SUBCOMMANDS"))
588 sections.push(renderTable(
589 ungrouped.commands.map((sub) => ({
590 left: colors.cyan(formatSubcommandName(sub.name, sub.alias)),
591 right: sub.shortDescription ?? sub.description
592 })),
593 20
594 ))
595 if (doc.subcommands.length > 1) {
596 sections.push("")
597 }
598 }
599
600 for (const group of doc.subcommands) {
601 if (group.group === undefined) continue
602 sections.push(colors.bold(`${group.group}:`))
603 sections.push(renderTable(
604 group.commands.map((sub) => ({
605 left: colors.cyan(formatSubcommandName(sub.name, sub.alias)),
606 right: sub.shortDescription ?? sub.description
607 })),
608 20
609 ))
610 sections.push("")
611 }
612 }
613
614 // Examples section
615 if (doc.examples && doc.examples.length > 0) {
616 sections.push(colors.bold("EXAMPLES"))
617
618 let first = true
619 let previousHadDescription = false
620 for (const example of doc.examples) {
621 if (example.description) {
622 if (!first) sections.push("")
623 sections.push(` ${colors.dim(`# ${example.description}`)}`)
624 } else if (previousHadDescription) {
625 sections.push("")
626 }
627 sections.push(` ${colors.cyan(example.command)}`)
628 first = false
629 previousHadDescription = !!example.description
630 }
631 sections.push("")
632 }
633
634 // Remove trailing empty line if present
635 if (sections[sections.length - 1] === "") {
636 sections.pop()
637 }
638
639 return sections.join("\n")
640}