UNPKG

@optique/core

Version:

Type-safe combinatorial command-line interface parser

239 lines (238 loc) 8 kB
//#region src/usage.ts /** * Formats a usage description into a human-readable string representation * suitable for command-line help text. * * This function converts a structured {@link Usage} description into a * formatted string that follows common CLI conventions. It supports various * formatting options including colors and compact option display. * @param programName The name of the program or command for which the usage * description is being formatted. This is typically the * name of the executable or script that the user will run. * @param usage The usage description to format, consisting of an array * of usage terms representing the command-line structure. * @param options Optional formatting options to customize the output. * See {@link UsageFormatOptions} for available options. * @returns A formatted string representation of the usage description. */ function formatUsage(programName, usage, options = {}) { usage = normalizeUsage(usage); if (options.expandCommands) { const lastTerm = usage.at(-1); if (usage.length > 0 && usage.slice(0, -1).every((t) => t.type === "command") && lastTerm.type === "exclusive" && lastTerm.terms.every((t) => t.length > 0 && (t[0].type === "command" || t[0].type === "option" || t[0].type === "argument" || t[0].type === "optional" && t[0].terms.length === 1 && (t[0].terms[0].type === "command" || t[0].terms[0].type === "option" || t[0].terms[0].type === "argument")))) { const lines = []; for (let command of lastTerm.terms) { if (usage.length > 1) command = [...usage.slice(0, -1), ...command]; lines.push(formatUsage(programName, command, options)); } return lines.join("\n"); } } let output = options.colors ? `\x1b[1m${programName}\x1b[0m ` : `${programName} `; let lineWidth = programName.length + 1; for (const { text, width } of formatUsageTerms(usage, options)) { if (options.maxWidth != null && lineWidth + width > options.maxWidth) { output += "\n"; lineWidth = 0; if (text === " ") continue; } output += text; lineWidth += width; } return output; } /** * Normalizes a usage description by flattening nested exclusive terms, * sorting terms for better readability, and ensuring consistent structure * throughout the usage tree. * * This function performs two main operations: * * 1. *Flattening*: Recursively processes all usage terms and merges any * nested exclusive terms into their parent exclusive term to avoid * redundant nesting. For example, an exclusive term containing another * exclusive term will have its nested terms flattened into the parent. * * 2. *Sorting*: Reorders terms to improve readability by placing: * - Commands (subcommands) first * - Options and other terms in the middle * - Positional arguments last (including optional/multiple wrappers around * arguments) * * The sorting logic also recognizes when optional or multiple terms contain * positional arguments and treats them as arguments for sorting purposes. * * @param usage The usage description to normalize. * @returns A normalized usage description with flattened exclusive terms * and terms sorted for optimal readability. */ function normalizeUsage(usage) { const terms = usage.map(normalizeUsageTerm); terms.sort((a, b) => { const aCmd = a.type === "command"; const bCmd = b.type === "command"; const aArg = a.type === "argument" || (a.type === "optional" || a.type === "multiple") && a.terms.at(-1)?.type === "argument"; const bArg = b.type === "argument" || (b.type === "optional" || b.type === "multiple") && b.terms.at(-1)?.type === "argument"; return aCmd === bCmd ? aArg === bArg ? 0 : aArg ? 1 : -1 : aCmd ? -1 : 1; }); return terms; } function normalizeUsageTerm(term) { if (term.type === "optional") return { type: "optional", terms: normalizeUsage(term.terms) }; else if (term.type === "multiple") return { type: "multiple", terms: normalizeUsage(term.terms), min: term.min }; else if (term.type === "exclusive") { const terms = []; for (const usage of term.terms) { const normalized = normalizeUsage(usage); if (normalized.length === 1 && normalized[0].type === "exclusive") for (const subUsage of normalized[0].terms) terms.push(subUsage); else terms.push(normalized); } return { type: "exclusive", terms }; } else return term; } function* formatUsageTerms(terms, options) { let i = 0; for (const t of terms) { if (i > 0) yield { text: " ", width: 1 }; yield* formatUsageTermInternal(t, options); i++; } } /** * Formats a single {@link UsageTerm} into a string representation * suitable for command-line help text. * @param term The usage term to format, which can be an argument, * option, command, optional term, exclusive term, or multiple term. * @param options Optional formatting options to customize the output. * See {@link UsageTermFormatOptions} for available options. * @returns A formatted string representation of the usage term. */ function formatUsageTerm(term, options = {}) { let lineWidth = 0; let output = ""; for (const { text, width } of formatUsageTermInternal(term, options)) { if (options.maxWidth != null && lineWidth + width > options.maxWidth) { output += "\n"; lineWidth = 0; if (text === " ") continue; } output += text; lineWidth += width; } return output; } function* formatUsageTermInternal(term, options) { const optionsSeparator = options.optionsSeparator ?? "/"; if (term.type === "argument") yield { text: options?.colors ? `\x1b[4m${term.metavar}\x1b[0m` : term.metavar, width: term.metavar.length }; else if (term.type === "option") if (options?.onlyShortestOptions) { const shortestName = term.names.reduce((a, b) => a.length <= b.length ? a : b); yield { text: options?.colors ? `\x1b[3m${shortestName}\x1b[0m` : shortestName, width: shortestName.length }; } else { let i = 0; for (const optionName of term.names) { if (i > 0) yield { text: options?.colors ? `\x1b[2m${optionsSeparator}\x1b[0m` : optionsSeparator, width: optionsSeparator.length }; yield { text: options?.colors ? `\x1b[3m${optionName}\x1b[0m` : optionName, width: optionName.length }; i++; } if (term.metavar != null) { yield { text: " ", width: 1 }; yield { text: options?.colors ? `\x1b[4m\x1b[2m${term.metavar}\x1b[0m` : term.metavar, width: term.metavar.length }; } } else if (term.type === "command") yield { text: options?.colors ? `\x1b[1m${term.name}\x1b[0m` : term.name, width: term.name.length }; else if (term.type === "optional") { yield { text: options?.colors ? `\x1b[2m[\x1b[0m` : "[", width: 1 }; yield* formatUsageTerms(term.terms, options); yield { text: options?.colors ? `\x1b[2m]\x1b[0m` : "]", width: 1 }; } else if (term.type === "exclusive") { yield { text: options?.colors ? `\x1b[2m(\x1b[0m` : "(", width: 1 }; let i = 0; for (const termGroup of term.terms) { if (i > 0) { yield { text: " ", width: 1 }; yield { text: "|", width: 1 }; yield { text: " ", width: 1 }; } yield* formatUsageTerms(termGroup, options); i++; } yield { text: options?.colors ? `\x1b[2m)\x1b[0m` : ")", width: 1 }; } else if (term.type === "multiple") { if (term.min < 1) yield { text: options?.colors ? `\x1b[2m[\x1b[0m` : "[", width: 1 }; for (let i = 0; i < Math.max(1, term.min); i++) { if (i > 0) yield { text: " ", width: 1 }; yield* formatUsageTerms(term.terms, options); } yield { text: options?.colors ? `\x1b[2m...\x1b[0m` : "...", width: 3 }; if (term.min < 1) yield { text: options?.colors ? `\x1b[2m]\x1b[0m` : "]", width: 1 }; } else throw new TypeError(`Unknown usage term type: ${term["type"]}.`); } //#endregion export { formatUsage, formatUsageTerm, normalizeUsage };