mirror of
https://github.com/chenasraf/massarg.git
synced 2026-05-17 17:38:09 +00:00
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import z from 'zod'
|
|
import { format, StringStyle, stripStyle } from './style'
|
|
import { MassargCommand } from './command'
|
|
import { DeepRequired, strConcat, indent, deepMerge } from './utils'
|
|
import {
|
|
NEGATE_FULL_PREFIX,
|
|
NEGATE_SHORT_PREFIX,
|
|
OPT_FULL_PREFIX,
|
|
OPT_SHORT_PREFIX,
|
|
} from './option'
|
|
|
|
export const GenerateTableCommandConfig = z.object({
|
|
/** Length of each row in the table */
|
|
lineLength: z.number().optional(),
|
|
/** When `false`, each row is separated by a blank line */
|
|
compact: z.boolean().optional(),
|
|
/** Style of the command/option name */
|
|
nameStyle: StringStyle.optional(),
|
|
/** Style of the command/option description */
|
|
descriptionStyle: StringStyle.optional(),
|
|
/** Prefix for the command/option name (default is the command's prefix) */
|
|
namePrefix: z.string().optional(),
|
|
/** Prefix for the command/option aliases (default is the command's prefix) */
|
|
aliasPrefix: z.string().optional(),
|
|
})
|
|
export type GenerateTableCommandConfig = z.infer<typeof GenerateTableCommandConfig>
|
|
|
|
export const GenerateTableOptionConfig = GenerateTableCommandConfig.merge(
|
|
z.object({
|
|
/** Prefix for the command/option negations (default is the command's prefix) */
|
|
negatePrefix: z.string().optional(),
|
|
/** Prefix for the command/option negation aliases (default is the command's prefix) */
|
|
negateAliasPrefix: z.string().optional(),
|
|
/** Whether to display negations with each option name */
|
|
displayNegations: z.boolean().optional(),
|
|
}),
|
|
)
|
|
export type GenerateTableOptionConfig = z.infer<typeof GenerateTableOptionConfig>
|
|
|
|
export const HelpConfig = z.object({
|
|
/**
|
|
* Whether to bind the help command to this command
|
|
*
|
|
* Set this to `true` to automatically add a `help` command to this command's subcommands.
|
|
*/
|
|
bindCommand: z.boolean().optional(),
|
|
/**
|
|
* Whether to bind the help option to this command
|
|
*
|
|
* Set this to `true` to automatically add a `--help` option to this command's options.
|
|
*/
|
|
bindOption: z.boolean().optional(),
|
|
|
|
/** Whether to align all tables to the column widths, or have each table be independent. Default is `true` */
|
|
useGlobalTableColumns: z.boolean().default(true).optional(),
|
|
|
|
/** Options for generating the table of commands */
|
|
commandOptions: GenerateTableCommandConfig.omit({ lineLength: true }).optional(),
|
|
/** Options for generating the table of options */
|
|
optionOptions: GenerateTableOptionConfig.omit({ lineLength: true }).optional(),
|
|
/** Style of the help title */
|
|
titleStyle: StringStyle.optional(),
|
|
/** Style of the help description */
|
|
descriptionStyle: StringStyle.optional(),
|
|
/** Style of the help subtitles for commands, options and examples */
|
|
subtitleStyle: StringStyle.optional(),
|
|
/** Style of the help usage */
|
|
usageStyle: z
|
|
.object({
|
|
/** Style of the help usage title - appears before the usage text (custom or auto) */
|
|
prefix: StringStyle.optional(),
|
|
/**
|
|
* For custom usage text, this is the primary style used (right after the `prefix` style).
|
|
* For automated usage text, this style is used for the command/binary name
|
|
*/
|
|
main: StringStyle.optional(),
|
|
/** Style of the help usage commands (if any) */
|
|
command: StringStyle.optional(),
|
|
/** Style of the help usage options (if any) */
|
|
options: StringStyle.optional(),
|
|
})
|
|
.optional(),
|
|
/** Style of the help header */
|
|
headerStyle: StringStyle.optional(),
|
|
/** Style of the help footer */
|
|
footerStyle: StringStyle.optional(),
|
|
/** Maximum length of a row in the help output */
|
|
lineLength: z.number().optional(),
|
|
/** Options for examples section */
|
|
exampleOptions: z
|
|
.object({
|
|
/** Style of the example description */
|
|
descriptionStyle: StringStyle.optional(),
|
|
/** Style of the example input */
|
|
inputStyle: StringStyle.optional(),
|
|
/** Style of the example output */
|
|
outputStyle: StringStyle.optional(),
|
|
/** Prefix for the example input (default: `$`) */
|
|
inputPrefix: z.string().default('$').optional(),
|
|
/** Prefix for the example output (default: `>`) */
|
|
outputPrefix: z.string().default('>').optional(),
|
|
})
|
|
.optional(),
|
|
/** Text to display at the very top, describing CLI usage */
|
|
usageText: z.string().optional(),
|
|
/** Text to display above the description, below the usage */
|
|
headerText: z.string().optional(),
|
|
/** Text to display at the very bottom, below the examples */
|
|
footerText: z.string().optional(),
|
|
})
|
|
|
|
export type HelpConfig = z.infer<typeof HelpConfig>
|
|
|
|
export const defaultHelpConfig: DeepRequired<HelpConfig> = {
|
|
lineLength: 80,
|
|
useGlobalTableColumns: true,
|
|
commandOptions: {
|
|
nameStyle: {
|
|
color: 'yellow',
|
|
},
|
|
descriptionStyle: {
|
|
color: 'gray',
|
|
},
|
|
},
|
|
optionOptions: {
|
|
namePrefix: OPT_FULL_PREFIX,
|
|
aliasPrefix: OPT_SHORT_PREFIX,
|
|
negatePrefix: NEGATE_FULL_PREFIX,
|
|
negateAliasPrefix: NEGATE_SHORT_PREFIX,
|
|
displayNegations: false,
|
|
nameStyle: {
|
|
color: 'yellow',
|
|
},
|
|
descriptionStyle: {
|
|
color: 'gray',
|
|
},
|
|
},
|
|
descriptionStyle: {},
|
|
exampleOptions: {
|
|
descriptionStyle: {
|
|
bold: true,
|
|
color: 'brightWhite',
|
|
},
|
|
inputStyle: {
|
|
color: 'yellow',
|
|
},
|
|
outputStyle: {
|
|
color: 'brightWhite',
|
|
},
|
|
inputPrefix: '$',
|
|
outputPrefix: '>',
|
|
},
|
|
bindCommand: false,
|
|
bindOption: false,
|
|
titleStyle: {
|
|
bold: true,
|
|
color: 'yellow',
|
|
},
|
|
usageStyle: {
|
|
prefix: {
|
|
bold: true,
|
|
color: 'brightWhite',
|
|
},
|
|
main: {
|
|
color: 'yellow',
|
|
},
|
|
command: {
|
|
color: 'gray',
|
|
},
|
|
options: {
|
|
color: 'gray',
|
|
},
|
|
},
|
|
subtitleStyle: {
|
|
bold: true,
|
|
color: 'brightWhite',
|
|
underline: true,
|
|
},
|
|
headerText: '',
|
|
footerText: '',
|
|
usageText: '',
|
|
headerStyle: {},
|
|
footerStyle: {},
|
|
}
|
|
|
|
export type HelpItem = {
|
|
name: string
|
|
aliases: string[]
|
|
description: string
|
|
hidden?: boolean
|
|
}
|
|
|
|
export class HelpGenerator {
|
|
entry: MassargCommand<any>
|
|
config: DeepRequired<HelpConfig>
|
|
|
|
constructor(entry: MassargCommand<any>, config?: HelpConfig) {
|
|
this.entry = entry
|
|
this.config = HelpConfig.required().parse(deepMerge(entry.helpConfig, config))
|
|
}
|
|
|
|
generate(): string {
|
|
const entry = this.entry
|
|
const CMD_OPT_INDENT = 4
|
|
const _wrap = (text: string, indent = 0) => wrap(text, this.config.lineLength - indent)
|
|
const optionOptions = {
|
|
...this.config.optionOptions,
|
|
lineLength: this.config.lineLength - CMD_OPT_INDENT,
|
|
}
|
|
const commandOptions = {
|
|
...this.config.commandOptions,
|
|
displayNegations: false,
|
|
lineLength: this.config.lineLength - CMD_OPT_INDENT,
|
|
}
|
|
const maxNameLength = this.config.useGlobalTableColumns
|
|
? Math.max(
|
|
getMaxNameLength(entry.options.map((e) => getItemDetails(e, optionOptions))),
|
|
getMaxNameLength(entry.commands.map((e) => getItemDetails(e, commandOptions))),
|
|
)
|
|
: undefined
|
|
const options = generateHelpTable(entry.options, optionOptions, maxNameLength).trimEnd()
|
|
const commands = generateHelpTable(entry.commands, commandOptions, maxNameLength).trimEnd()
|
|
const examples = entry.examples
|
|
.map((example) => {
|
|
const { description, input, output } = example
|
|
return strConcat(
|
|
description && [
|
|
_wrap(format(description, this.config.exampleOptions.descriptionStyle), 4),
|
|
],
|
|
input &&
|
|
_wrap(
|
|
format(
|
|
[this.config.exampleOptions.inputPrefix, input].filter(Boolean).join(' '),
|
|
this.config.exampleOptions.inputStyle,
|
|
),
|
|
4,
|
|
),
|
|
output &&
|
|
_wrap(
|
|
format(
|
|
[this.config.exampleOptions.outputPrefix, output].filter(Boolean).join(' '),
|
|
this.config.exampleOptions.outputStyle,
|
|
),
|
|
4,
|
|
),
|
|
)
|
|
})
|
|
.join('\n')
|
|
const { headerText, footerText, usageText } = this.config
|
|
|
|
return (
|
|
strConcat(
|
|
_wrap(
|
|
usageText
|
|
? strConcat(
|
|
format('Usage:', this.config.usageStyle.prefix),
|
|
format(usageText, this.config.usageStyle.main),
|
|
)
|
|
: [
|
|
format(`Usage:`, this.config.usageStyle.prefix),
|
|
format(entry.name, this.config.usageStyle.main),
|
|
commands.length && format('[command]', this.config.usageStyle.command),
|
|
options.length && format('[options]', this.config.usageStyle.options),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
),
|
|
headerText.length && ['', format(headerText, this.config.descriptionStyle)],
|
|
entry.description.length && [
|
|
'',
|
|
_wrap(format(entry.description, this.config.descriptionStyle)),
|
|
],
|
|
commands.length &&
|
|
indent([
|
|
'',
|
|
format(
|
|
entry.parent ? `Commands for ${entry.name}:` : 'Commands:',
|
|
this.config.subtitleStyle,
|
|
),
|
|
'',
|
|
indent(commands),
|
|
]),
|
|
options.length &&
|
|
indent([
|
|
'',
|
|
format(
|
|
entry.parent ? `Options for ${entry.name}:` : 'Options:',
|
|
this.config.subtitleStyle,
|
|
),
|
|
'',
|
|
indent(options),
|
|
]),
|
|
examples.length &&
|
|
indent(['', format('Examples:', this.config.subtitleStyle), '', indent(examples)]),
|
|
footerText.length && ['', _wrap(format(footerText, this.config.descriptionStyle))],
|
|
) + '\n'
|
|
)
|
|
}
|
|
|
|
printHelp(): void {
|
|
console.log(this.generate())
|
|
}
|
|
}
|
|
|
|
function wrap(text: string, lineLength: number): string {
|
|
const length = stripStyle(text).length
|
|
if (length <= lineLength) {
|
|
return text
|
|
}
|
|
const subRows: string[] = []
|
|
const words = text.split(' ')
|
|
let currentRow = ''
|
|
|
|
for (const word of words) {
|
|
if (stripStyle(currentRow).length + stripStyle(word).length + 1 > lineLength) {
|
|
subRows.push(currentRow)
|
|
currentRow = ''
|
|
}
|
|
currentRow += `${word} `
|
|
}
|
|
subRows.push(currentRow)
|
|
|
|
return subRows.join('\n')
|
|
}
|
|
|
|
type ParsedHelpItem = {
|
|
name: string
|
|
description: string
|
|
hidden: boolean
|
|
}
|
|
|
|
const getMaxNameLength = (items: ParsedHelpItem[]): number =>
|
|
Math.max(...items.map((o) => o.name.length))
|
|
|
|
function getItemDetails(
|
|
o: HelpItem,
|
|
options?: Pick<
|
|
GenerateTableOptionConfig & GenerateTableOptionConfig,
|
|
'displayNegations' | 'namePrefix' | 'aliasPrefix' | 'negatePrefix' | 'negateAliasPrefix'
|
|
>,
|
|
): ParsedHelpItem {
|
|
const {
|
|
displayNegations = false,
|
|
namePrefix = '',
|
|
negatePrefix = '',
|
|
aliasPrefix = '',
|
|
negateAliasPrefix = '',
|
|
} = options ?? {}
|
|
const cmdNames = {
|
|
full: `${namePrefix}${o.name}`,
|
|
fullNegated: negatePrefix ? `${negatePrefix}${o.name}` : undefined,
|
|
aliases: o.aliases.map((a) => `${aliasPrefix}${a}`).join(' | '),
|
|
aliasesNegated: negatePrefix
|
|
? o.aliases.map((a) => `${negateAliasPrefix}${a}`).join(' | ')
|
|
: undefined,
|
|
}
|
|
const name = [
|
|
cmdNames.full,
|
|
cmdNames.aliases,
|
|
displayNegations && cmdNames.fullNegated,
|
|
displayNegations && cmdNames.aliasesNegated,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' | ')
|
|
const description = o.description
|
|
const hidden = o.hidden || false
|
|
return { name, description, hidden }
|
|
}
|
|
|
|
function generateHelpTable<T extends GenerateTableCommandConfig | GenerateTableOptionConfig>(
|
|
items: HelpItem[],
|
|
fullConfig: Partial<T> = {},
|
|
maxNameLength?: number,
|
|
): string {
|
|
const {
|
|
lineLength = 80,
|
|
namePrefix = '',
|
|
aliasPrefix = '',
|
|
negatePrefix = '',
|
|
negateAliasPrefix = '',
|
|
displayNegations = false,
|
|
compact = false,
|
|
...config
|
|
} = fullConfig as GenerateTableOptionConfig
|
|
const rows = items
|
|
.map((o) =>
|
|
getItemDetails(o, {
|
|
namePrefix,
|
|
aliasPrefix,
|
|
negatePrefix,
|
|
negateAliasPrefix,
|
|
}),
|
|
)
|
|
.filter((r) => !r.hidden)
|
|
maxNameLength ??= getMaxNameLength(rows)
|
|
const nameStyle = (name: string) => format(name, config.nameStyle)
|
|
const descStyle = (desc: string) => format(desc, config.descriptionStyle)
|
|
const table = rows.map((row) => {
|
|
const name = nameStyle(row.name.padEnd(maxNameLength! + 2))
|
|
const description = descStyle(row.description)
|
|
const length = stripStyle(name).length + stripStyle(description).length
|
|
if (length <= lineLength) {
|
|
const line = `${name}${description}`
|
|
if (!compact) {
|
|
return `${line}\n`
|
|
}
|
|
return line
|
|
}
|
|
const subRows: string[] = []
|
|
const words = description.split(' ')
|
|
let currentRow = name
|
|
|
|
for (const word of words) {
|
|
if (stripStyle(currentRow).length + stripStyle(word).length + 1 > lineLength) {
|
|
subRows.push(currentRow)
|
|
currentRow = ' '.repeat(maxNameLength! + 2)
|
|
}
|
|
currentRow += `${word} `
|
|
}
|
|
|
|
if (!compact) {
|
|
subRows.push('')
|
|
}
|
|
|
|
return subRows.join('\n')
|
|
})
|
|
|
|
return table.join('\n')
|
|
}
|