feat: help generator

This commit is contained in:
2023-11-19 12:55:20 +02:00
committed by Chen Asraf
parent e110498f3b
commit 4051864429
11 changed files with 976 additions and 1055 deletions

View File

@@ -22,13 +22,12 @@
"test": "jest"
},
"devDependencies": {
"@types/jest": "^27.5.2",
"@types/lodash": "^4.14.177",
"@types/node": "^16.18.61",
"jest": "^27.5.1",
"ts-jest": "^27.1.5",
"@types/jest": "^29.5.8",
"@types/node": "^20.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
"typescript": "^5.2.2"
},
"dependencies": {
"zod": "^3.22.4"

1417
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

45
src/color.ts Normal file
View File

@@ -0,0 +1,45 @@
export const ansiStyles = {
reset: "\x1b[0m",
bold: "\x1b[1m",
underline: "\x1b[4m",
black: "\x1b[30m",
}
export const ansiColors = {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m", // warning
blue: "\x1b[34m",
magenta: "\x1b[35m", // error
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
grey: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
}
export type StringStyle = {
color?: keyof typeof ansiColors
bold?: boolean
underline?: boolean
reset?: boolean
}
export function format(string: string, style: StringStyle = {}): string {
const { color, bold, underline, reset } = style
const colorCode = color ? ansiColors[color] : ""
const boldCode = bold ? ansiStyles.bold : ""
const underlineCode = underline ? ansiStyles.underline : ""
const resetCode = reset ? ansiStyles.reset : ""
return `${colorCode}${boldCode}${underlineCode}${string}${resetCode}`
}
export function stripColors(string: string): string {
return string.replace(/\x1b\[\d+m/g, "")
}

View File

@@ -1,8 +1,8 @@
import { z } from "zod"
import { ValidationError } from "./error"
import Massarg from "./massarg"
import MassargOption, { TypedOptionConfig } from "./option"
import { generateCommandsHelpTable, generateOptionsHelpTable, isZodError, setOrPush } from "./utils"
import { isZodError, ValidationError } from "./error"
import { HelpGenerator } from "./help"
import MassargOption, { MassargFlag, OptionConfig, TypedOptionConfig } from "./option"
import { setOrPush } from "./utils"
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
z.object({
@@ -12,25 +12,23 @@ export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
run: z
.function()
.args(args, z.any())
.returns(z.union([z.promise(z.void()), z.void()])) as z.ZodType<
(args: z.infer<RunArgs>, instance: MassargCommand<z.infer<RunArgs>>) => Promise<void> | void
>,
.returns(z.union([z.promise(z.void()), z.void()])) as z.ZodType<Runner<z.infer<RunArgs>>>,
})
export type CommandConfig<T = unknown> = z.infer<ReturnType<typeof CommandConfig<z.ZodType<T>>>>
export type ArgsObject = Record<string, unknown>
// export type RunFn<Args extends ArgsObject> = (options: Args) => Promise<void> | void
export type Runner<Args extends ArgsObject> = <A extends ArgsObject = Args>(
options: A,
instance: MassargCommand<A>,
) => Promise<void> | void
export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
name: string
description: string
aliases: string[]
private _run?: <P extends ArgsObject = Args>(
options: Args,
instance: Massarg<P>,
) => Promise<void> | void
private _run?: Runner<Args>
options: MassargOption[] = []
commands: MassargCommand<any>[] = []
args: Partial<Args> = {}
@@ -40,7 +38,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
this.name = options.name
this.description = options.description
this.aliases = options.aliases ?? []
this._run = options.run as typeof this._run
this._run = options.run
}
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args>
@@ -64,6 +62,25 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
}
}
flag(config: Omit<OptionConfig<boolean>, "parse">): MassargCommand<Args>
flag(config: MassargFlag): MassargCommand<Args>
flag(config: Omit<OptionConfig<boolean>, "parse"> | MassargFlag): MassargCommand<Args> {
try {
const flag = config instanceof MassargFlag ? config : new MassargFlag(config)
this.options.push(flag as MassargOption)
return this
} catch (e) {
if (isZodError(e)) {
throw new ValidationError({
path: [this.name, config.name, ...e.issues[0].path.map((p) => p.toString())],
code: e.issues[0].code,
message: e.issues[0].message,
})
}
throw e
}
}
option<T = string>(config: MassargOption<T>): MassargCommand<Args>
option<T = string>(config: TypedOptionConfig<T>): MassargCommand<Args>
option<T = string>(config: TypedOptionConfig<T> | MassargOption<T>): MassargCommand<Args> {
@@ -84,10 +101,8 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
}
}
main<A extends ArgsObject = Args>(
run: (options: Args, instance: MassargCommand<A>) => Promise<void> | void,
): MassargCommand<Args> {
this._run = run as typeof this._run
main<A extends ArgsObject = Args>(run: Runner<A>): MassargCommand<Args> {
this._run = run
return this
}
@@ -118,7 +133,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
const option = this.options.find((o) => o._match(arg))
if (!option) {
throw new ValidationError({
path: [arg],
path: [MassargOption.getName(arg)],
code: "unknown_option",
message: "Unknown option",
})
@@ -142,7 +157,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
const option = this.options.find((o) => o._match(arg))
if (!option) {
throw new ValidationError({
path: [arg],
path: [MassargOption.getName(arg)],
code: "unknown_option",
message: "Unknown option",
})
@@ -165,19 +180,7 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
}
helpString(): string {
const options = generateOptionsHelpTable(this.options)
const commands = generateCommandsHelpTable(this.commands)
return [
`${this.name} - ${this.description}`,
commands.length && "",
commands.length && `Commands for ${this.name}:`,
commands.length && commands,
options.length && "",
options.length && `Options for ${this.name}:`,
options.length && options,
]
.filter((s) => typeof s === "string")
.join("\n")
return new HelpGenerator(this).generate()
}
printHelp(): void {
@@ -185,4 +188,37 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
}
}
export class MassargHelpCommand<T extends ArgsObject = ArgsObject> extends MassargCommand<T> {
constructor(config: Partial<Omit<CommandConfig<T>, "run">>) {
super({
name: "help",
aliases: ["h"],
description: "Print help",
run: (args, parent) => {
if (args.command) {
const command = parent.commands.find((c) => c.name === args.command)
if (command) {
command.printHelp()
return
} else {
throw new ValidationError({
path: ["command"],
code: "unknown_command",
message: "Unknown command",
})
}
}
parent.printHelp()
},
...config,
})
this.option({
name: "command",
aliases: ["c"],
description: "Command to print help for",
isDefault: true,
})
}
}
export { MassargCommand }

View File

@@ -1,3 +1,5 @@
import { z } from "zod"
export class ValidationError extends Error {
path: string[]
code: string
@@ -41,3 +43,7 @@ export class ParseError extends Error {
this.received = received
}
}
export function isZodError(e: unknown): e is z.ZodError {
return e instanceof z.ZodError
}

View File

@@ -3,6 +3,62 @@ import MassargCommand from "./command"
import { ParseError } from "./error"
type A = { test: boolean }
const addCmd = massarg<{ component: string }>({
name: "add",
description: "Add a component",
aliases: ["a"],
// run: (opts, parser) => {
// parser.printHelp()
// console.log("Adding component", opts.component)
// },
})
.option({
name: "component",
description:
"Component to add. Ut consectetur eu et occaecat enim magna amet eiusmod laboris deserunt proident culpa nulla ipsum adipiscing ullamco laboris sed est",
aliases: ["c"],
// aliases: "" as never,
})
.option({
name: "classes",
description: "Classes to add",
aliases: ["l"],
array: true,
})
.option({
name: "custom",
description: "Custom option",
aliases: ["x"],
parse: (value) => {
const asNumber = Number(value)
if (isNaN(asNumber)) {
throw new ParseError({
path: ["custom"],
message: "Custom option must be a number",
code: "invalid_number",
})
}
return {
value: asNumber,
half: asNumber / 2,
double: asNumber * 2,
}
},
})
const removeCmd = new MassargCommand<{ component: string }>({
name: "remove",
description: "Remove a component",
aliases: ["r"],
run: (opts) => {
console.log("Removing component", opts.component)
},
}).option({
name: "component",
description: "Component to remove",
aliases: ["c"],
})
const args = massarg<A>({
name: "my-cli",
description: "This is an example CLI",
@@ -12,68 +68,12 @@ const args = massarg<A>({
console.log(opts, "\n")
parser.printHelp()
})
.command(
massarg<{ component: string }>({
name: "add",
description: "Add a component",
aliases: ["a"],
run: (opts) => {
console.log("Adding component", opts.component)
},
})
.option({
name: "component",
description: "Component to add",
aliases: ["c"],
// aliases: "" as never,
})
.option({
name: "classes",
description: "Classes to add",
aliases: ["l"],
array: true,
})
.option({
name: "custom",
description: "Custom option",
aliases: ["x"],
parse: (value) => {
const asNumber = Number(value)
if (isNaN(asNumber)) {
throw new ParseError({
path: ["custom"],
message: "Custom option must be a number",
code: "invalid_number",
})
}
return {
value: asNumber,
half: asNumber / 2,
double: asNumber * 2,
}
},
}),
)
.command(
new MassargCommand<{ component: string }>({
name: "remove",
description: "Remove a component",
aliases: ["r"],
run: (opts) => {
console.log("Removing component", opts.component)
},
}).option({
name: "component",
description: "Component to remove",
aliases: ["c"],
// aliases: "" as never,
}),
)
.option({
.command(addCmd)
.command(removeCmd)
.flag({
name: "bool",
description: "Example boolean option",
aliases: ["b"],
type: "boolean",
})
.option({
name: "number",
@@ -82,8 +82,6 @@ const args = massarg<A>({
type: "number",
})
const opts = args.getArgs(process.argv.slice(2))
console.log("Opts:", opts, "\n")
// console.log("Opts:", args.getArgs(process.argv.slice(2)), "\n")
args.parse(process.argv.slice(2))

188
src/help.ts Normal file
View File

@@ -0,0 +1,188 @@
import { format, StringStyle, stripColors } from "./color"
import MassargCommand from "./command"
export type GenerateTableCommandConfig = {
maxRowLength?: number
namePrefix?: string
aliasPrefix?: string
compact?: boolean
nameStyle?: StringStyle
descriptionStyle?: StringStyle
}
export type GenerateTableOptionConfig = GenerateTableCommandConfig & {
typeStyle?: StringStyle
defaultStyle?: StringStyle
}
export type GenerateHelpOptions = {
// sub-styles
commandOptions?: GenerateTableCommandConfig
optionOptions?: GenerateTableOptionConfig
// global styles
titleStyle?: StringStyle
descriptionStyle?: StringStyle
subtitleStyle?: StringStyle
usageStyle?: StringStyle
}
export type HelpItem = {
name: string
aliases: string[]
description: string
}
export class HelpGenerator {
entry: MassargCommand<any>
config: GenerateHelpOptions
constructor(entry: MassargCommand<any>, config?: GenerateHelpOptions) {
this.entry = entry
this.config = {
commandOptions: {
...config?.commandOptions,
},
optionOptions: {
...config?.optionOptions,
},
}
}
generate(): string {
const entry = this.entry
const options = generateHelpTable(entry.options, {
namePrefix: "--",
aliasPrefix: "-",
...this.config.optionOptions,
})
const commands = generateHelpTable(entry.commands, {
namePrefix: "",
aliasPrefix: "",
...this.config.commandOptions,
})
return chainStr(
format(entry.name, {
bold: true,
color: "brightWhite",
reset: true,
...this.config.titleStyle,
}),
"",
format(entry.description, { reset: true, ...this.config.descriptionStyle }),
commands.length && [
"",
format(`Commands for ${entry.name}:`, {
bold: true,
reset: true,
color: "brightWhite",
underline: true,
...this.config.subtitleStyle,
}),
"",
commands,
],
options.length && [
"",
format(`Options for ${entry.name}:`, {
bold: true,
reset: true,
color: "brightWhite",
underline: true,
...this.config.subtitleStyle,
}),
"",
options,
],
)
}
printHelp(): void {
console.log(this.generate())
}
}
function generateHelpTable<T extends Partial<GenerateTableCommandConfig>>(
items: HelpItem[],
{
maxRowLength = 80,
namePrefix = "",
aliasPrefix = "",
compact = false,
...config
}: Partial<T> = {},
): string {
const rows = items.map((o) => {
const name = `${namePrefix}${o.name}${
o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : ""
}`
const description = o.description
return { name, description }
})
const maxNameLength = Math.max(...rows.map((o) => o.name.length))
const nameStyle = (name: string) =>
format(name, { bold: true, color: "brightWhite", reset: true, ...config.nameStyle })
const descStyle = (desc: string) =>
format(desc, { color: "gray", reset: true, ...config.descriptionStyle })
const table = rows.map((row) => {
const name = nameStyle(row.name.padEnd(maxNameLength + 2))
const description = descStyle(row.description)
const length = stripColors(name).length + stripColors(description).length
if (length <= maxRowLength) {
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 (stripColors(currentRow).length + stripColors(word).length + 1 > maxRowLength) {
subRows.push(currentRow)
currentRow = " ".repeat(maxNameLength + 2)
}
currentRow += `${word} `
}
if (!compact) {
subRows.push("")
}
return subRows.join("\n")
})
return table.join("\n")
}
type Parseable = string | number | boolean | Record<string, unknown>
function chainStr(...strs: (Parseable | Parseable[])[]) {
const res: string[] = []
for (const str of strs) {
if (typeof str === "string") {
res.push(str)
continue
}
if (Array.isArray(str)) {
res.push(chainStr(...str))
continue
}
if (typeof str === "object") {
for (const [key, value] of Object.entries(str)) {
if (Boolean(value)) {
res.push(key)
}
}
continue
}
if (Boolean(str)) {
res.push(str.toString())
continue
}
}
return res.join("\n")
}

View File

@@ -1,6 +1,5 @@
import { z } from "zod"
import { ParseError } from "./error"
import { isZodError } from "./utils"
import { isZodError, ParseError } from "./error"
export const OptionConfig = <T extends z.ZodType>(type: T) =>
z.object({
@@ -10,13 +9,15 @@ export const OptionConfig = <T extends z.ZodType>(type: T) =>
aliases: z.string().array(),
parse: z.function().args(z.string()).returns(type).optional(),
array: z.boolean().optional(),
required: z.boolean().optional(),
isDefault: z.boolean().optional(),
})
export type OptionConfig<T = unknown> = z.infer<ReturnType<typeof OptionConfig<z.ZodType<T>>>>
export const TypedOptionConfig = <T extends z.ZodType>(type: T) =>
OptionConfig(type).merge(
z.object({
type: z.enum(["string", "number", "boolean"]).optional(),
type: z.enum(["number"]).optional(),
}),
)
export type TypedOptionConfig<T = unknown> = z.infer<
@@ -63,8 +64,6 @@ export default class MassargOption<T = unknown> {
switch (config.type) {
case "number":
return new MassargNumber(config as OptionConfig<number>) as MassargOption<T>
case "boolean":
return new MassargFlag(config) as MassargOption<T>
}
return new MassargOption(config as OptionConfig<T>)
}
@@ -75,6 +74,14 @@ export default class MassargOption<T = unknown> {
let input = ""
try {
input = argv.shift()!
if (!this._match(argv[0])) {
throw new ParseError({
path: [this.name],
code: "invalid_option",
message: `Expected option ${this.name}`,
received: JSON.stringify(argv[0]),
})
}
const value = this.parse(input)
return { key: this.name, value, argv }
} catch (e) {
@@ -123,6 +130,25 @@ export default class MassargOption<T = unknown> {
arg.startsWith(NEGATE_SHORT_PREFIX)
)
}
static getName(arg: string): string {
if (arg.startsWith(OPT_FULL_PREFIX)) {
// negate full prefix
if (arg.startsWith(`--${NEGATE_FULL_PREFIX}`)) {
return arg.slice(`--${NEGATE_FULL_PREFIX}`.length)
}
return arg.slice(OPT_FULL_PREFIX.length)
}
// short prefix
if (arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)) {
return arg.slice(OPT_SHORT_PREFIX.length)
}
// negate short prefix
if (arg.startsWith(NEGATE_SHORT_PREFIX)) {
return arg.slice(NEGATE_SHORT_PREFIX.length)
}
return "<blank>"
}
}
export class MassargNumber extends MassargOption<number> {
@@ -169,7 +195,17 @@ export class MassargFlag extends MassargOption<boolean> {
_parseDetails(argv: string[]): ArgvValue<boolean> {
try {
const isNegation = argv[0]?.startsWith("^")
const isNegation =
argv[0]?.startsWith(NEGATE_SHORT_PREFIX) || argv[0]?.startsWith(NEGATE_FULL_PREFIX)
if (!this._match(argv[0])) {
throw new ParseError({
path: [this.name],
code: "invalid_option",
message: `Expected option ${this.name}`,
received: JSON.stringify(argv[0]),
})
}
argv.shift()
if (isNegation) {
return { key: this.name, value: false, argv }
@@ -188,4 +224,15 @@ export class MassargFlag extends MassargOption<boolean> {
}
}
export class MassargHelpFlag extends MassargFlag {
constructor(config: Partial<Omit<OptionConfig<boolean>, "parse">>) {
super({
name: "help",
description: "Show this help message",
aliases: ["h"],
...config,
})
}
}
export { MassargOption }

View File

@@ -1,77 +1,3 @@
import { z } from "zod"
import MassargCommand from "./command"
import MassargOption from "./option"
export function isZodError(e: unknown): e is z.ZodError {
return e instanceof z.ZodError
}
type GenerateTableOptions = {
maxRowLength?: number
namePrefix?: string
aliasPrefix?: string
}
/** generates an aligned table of options and their descriptions */
export function generateHelpTable(
items: { name: string; aliases: string[]; description: string }[],
{ maxRowLength = 80, namePrefix = "", aliasPrefix = "" }: GenerateTableOptions = {},
): string {
const rows = items.map((o) => {
const name = `${namePrefix}${o.name}${
o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : ""
}`
const description = o.description
return { name, description }
})
const maxNameLength = Math.max(...rows.map((o) => o.name.length))
const table = rows.map((row) => {
const name = row.name.padEnd(maxNameLength + 2)
const description = row.description
const length = name.length + description.length
if (length <= maxRowLength) {
return `${name}${description}`
}
const subRows: string[] = [name]
const words = description.split(" ")
let currentRow = subRows[0]
for (const word of words) {
if (currentRow.length + word.length + 1 > maxRowLength) {
subRows.push(currentRow)
currentRow = ""
}
currentRow += `${word} `
}
return subRows.join("\n")
})
return table.join("\n")
}
export function generateOptionsHelpTable(
options: MassargOption<unknown>[],
config?: GenerateTableOptions,
): string {
return generateHelpTable(options, {
namePrefix: "--",
aliasPrefix: "-",
...config,
})
}
export function generateCommandsHelpTable(
commands: MassargCommand[],
config?: GenerateTableOptions,
): string {
return generateHelpTable(commands, {
namePrefix: "",
aliasPrefix: "",
...config,
})
}
export function setOrPush<T>(
newValue: unknown,
currentValue: T[] | T | undefined,

View File

@@ -1,6 +1,6 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/sample.ts"
"src/example.ts"
]
}

View File

@@ -1,12 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"target": "ES2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "Node16", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"lib": [
"ES2020"
"ES2023"
], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */