@optique/core
Version:
Type-safe combinatorial command-line interface parser
239 lines (238 loc) • 8 kB
JavaScript
//#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 };