| 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 | |
| 14 | import * as Context from "../Context.ts" |
| 15 | import * as Layer from "../Layer.ts" |
| 16 | import * as Option from "../Option.ts" |
| 17 | import type * as CliError from "./CliError.ts" |
| 18 | import 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 | */ |
| 54 | export 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 | */ |
| 235 | export 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 | */ |
| 268 | export const layer = (formatter: Formatter): Layer.Layer<never> => Layer.succeed(Formatter)(formatter) |
| 269 | |
| 270 | const 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 | */ |
| 308 | export 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 | */ |
| 387 | const 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 | */ |
| 396 | const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) |
| 397 | |
| 398 | const zeroWidthCodePoints = /[\p{Mark}\p{Control}\p{Default_Ignorable_Code_Point}]/gu |
| 399 | const emojiPresentation = /\p{Emoji_Presentation}/u |
| 400 | |
| 401 | const 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 | |
| 420 | const 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 | |
| 427 | const 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 | */ |
| 439 | const 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 | */ |
| 449 | interface Row { |
| 450 | left: string |
| 451 | right: string |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Renders a table with aligned columns. |
| 456 | * @internal |
| 457 | */ |
| 458 | const 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 | |
| 464 | const formatSubcommandName = (name: string, alias: string | undefined): string => alias ? `${name}, ${alias}` : name |
| 465 | |
| 466 | /** |
| 467 | * Color functions interface for help formatting. |
| 468 | * @internal |
| 469 | */ |
| 470 | interface 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 | */ |
| 484 | const 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 | } |