import { bold } from "ansis" import { toKebabCase } from "remeda" import type { Command } from "./lib/command" import { getSubcommands } from "./lib/command" import { accent, dimmed } from "./lib/io" interface Row { description: string label: string width: number } /** * Prints help for a command and its position in the command tree. * * @param command - Command to render. * @param parentOrPath - Parent command or complete ancestor path. */ export function showUsage( command: Command, parentOrPath?: Command | readonly Command[], ) { console.log( `${renderUsage( command, !parentOrPath ? [] : Array.isArray(parentOrPath) ? parentOrPath : [parentOrPath], )}\n`, ) } /** * Renders help for a command and its position in the command tree. * * @param command - Command to render. * @param path - Ancestor commands. */ export function renderUsage(command: Command, path: readonly Command[] = []) { const commandName = [...path.map(({ name }) => name), command.name].join(" ") const optionRows = Object.entries(command.options ?? {}).flatMap( ([name, option]) => { const kebabName = toKebabCase(name) const rows = [ createRow( `${[option.short ? `-${option.short}` : undefined, `--${kebabName}`] .filter((label) => label !== undefined) .join(", ")}${option.type === "string" ? `=<${kebabName}>` : ""}`, [ command.optionDescriptions?.[name] ?? (name === "json" ? "Output JSON" : undefined), command.requiredOptions?.includes(name) ? dimmed`(Required)` : "", option.default === undefined ? "" : dimmed`(Default: ${option.default})`, ] .filter(Boolean) .join("\n"), ), ] if (option.type === "boolean" && !/^no[A-Z]/.test(name)) { rows.push( createRow( `--no-${kebabName}`, command.negativeOptionDescriptions?.[name] ?? `Negate ${accent(`--${kebabName}`)}`, ), ) } return rows }, ) const positionalRows = (command.positionals ?? []).map((positional) => createRow( dimmed(positional.name.toUpperCase()), [ positional.description, positional.required === false ? "" : dimmed`(Required)`, ] .filter(Boolean) .join(" "), positional.name.length, ), ) const subcommands = getSubcommands(command).filter(({ hidden }) => !hidden) const usageParts = [ optionRows.length > 0 || positionalRows.length > 0 ? "[OPTIONS]" : "", ...(command.positionals ?? []).map(({ name, required }) => required === false ? `[${name.toUpperCase()}]` : `<${name.toUpperCase()}>`, ), ...(command.requiredOptions?.map( (name) => `--${toKebabCase(name)}${command.options?.[name]?.type === "string" ? `=<${toKebabCase(name)}>` : ""}`, ) ?? []), subcommands.length > 0 ? "" : "", ].filter(Boolean) const lines = [ dimmed`${command.description} (${commandName}${command.version ? ` v${command.version}` : ""})`, "", `${bold.underline`USAGE`} ${accent(commandName)}${usageParts.length > 0 ? dimmed(` ${usageParts.join(" ")}`) : ""}`, "", ] if (positionalRows.length > 0) { lines.push(bold.underline`ARGUMENTS`, "", formatRows(positionalRows), "") } if (optionRows.length > 0) { lines.push(bold.underline`OPTIONS`, "", formatRows(optionRows), "") } if (subcommands.length > 0) { lines.push(bold.underline`COMMANDS`, "") if (command.groups) { const width = Math.max( ...command.groups.flatMap(({ commands }) => commands.filter(({ hidden }) => !hidden).map(commandLabelWidth), ), ) for (const { commands, heading } of command.groups) { const rows = commands.filter(({ hidden }) => !hidden).map(commandRow) if (rows.length === 0) continue lines.push(dimmed(heading), formatRows(rows, width), "") } } else { lines.push(formatRows(subcommands.map(commandRow)), "") } lines.push( `${accent(`${commandName} --help`)} for more information about a command.`, ) } return lines.join("\n") } /** @param command - Command to render as a help row. */ function commandRow(command: Command) { return createRow( [command.name, ...(command.aliases ?? [])].join("|"), command.description, ) } /** @param command - Command whose rendered label should be measured. */ function commandLabelWidth(command: Command) { return [command.name, ...(command.aliases ?? [])].join("|").length } /** * Creates one styled help row. * * @param label - Row label. * @param description - Row description. * @param width - Visible label width. */ function createRow(label: string, description: string, width = label.length) { return { description, label: accent(label), width } } /** * Aligns help rows into two columns. * * @param rows - Rows to format. * @param width - Shared label-column width. */ function formatRows( rows: readonly Row[], width = Math.max(...rows.map((row) => row.width)), ) { return rows .map(({ description, label, width: labelWidth }) => { const indent = " ".repeat(width + 4) const [first = "", ...rest] = description.split("\n") return [ ` ${" ".repeat(width - labelWidth)}${label} ${first}`, ...rest.map((line) => `${indent}${line}`), ].join("\n") }) .join("\n") }