From eca10e4c1d1423d84f0196e49b41aa08e1b9444e Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 19 Nov 2023 01:58:41 +0200 Subject: [PATCH] feat: array & typed options --- src/command.ts | 72 ++++++++++++++++++++++++++++-------------------- src/example.ts | 75 ++++++++++++++++++++++++++++++++++++++++---------- src/massarg.ts | 8 ++++-- src/option.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++---- src/utils.ts | 62 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 52 deletions(-) diff --git a/src/command.ts b/src/command.ts index 5159d4f..e8313d0 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { ValidationError } from "./error" -import MassargOption, { MassargFlag, MassargNumber, OptionConfig, OptionType } from "./option" -import { isZodError } from "./utils" +import MassargOption, { MassargFlag, MassargNumber, OptionConfig, TypedOptionConfig } from "./option" +import { generateCommandsHelpTable, generateOptionsHelpTable, isZodError } from "./utils" export const CommandConfig = (args: RunArgs) => z.object({ @@ -18,14 +18,16 @@ export type CommandConfig = z.infer +// export type RunFn = (options: Args) => Promise | void + export default class MassargCommand { name: string description: string - private aliases: string[] + aliases: string[] private _run?: (options: Args) => Promise | void - private options: MassargOption[] = [] - private commands: MassargCommand[] = [] - private args: Partial = {} + options: MassargOption[] = [] + commands: MassargCommand[] = [] + args: Partial = {} constructor(options: CommandConfig) { CommandConfig(z.any()).parse(options) @@ -35,9 +37,9 @@ export default class MassargCommand { this._run = options.run } - command(config: CommandConfig): MassargCommand - command(config: MassargCommand): MassargCommand - command(config: CommandConfig | MassargCommand): MassargCommand { + command(config: CommandConfig): MassargCommand + command(config: MassargCommand): MassargCommand + command(config: CommandConfig | MassargCommand): MassargCommand { try { const command = config instanceof MassargCommand ? config : new MassargCommand(config) this.commands.push(command) @@ -55,23 +57,10 @@ export default class MassargCommand { } option(config: MassargOption): MassargCommand - option(config: OptionConfig & { type?: OptionType }): MassargCommand - option(config: (OptionConfig & { type?: OptionType }) | MassargOption): MassargCommand { - const factory = () => { - if (!("type" in config)) { - return new MassargOption(config as OptionConfig) - } - switch (config.type) { - case "string": - return new MassargOption(config as OptionConfig) - case "number": - return new MassargNumber(config as OptionConfig) - case "boolean": - return new MassargFlag(config) - } - } + option(config: TypedOptionConfig): MassargCommand + option(config: TypedOptionConfig | MassargOption): MassargCommand { try { - const option = config instanceof MassargOption ? config : factory() + const option = config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config) this.options.push(option as MassargOption) return this } catch (e) { @@ -100,7 +89,8 @@ export default class MassargCommand { while (_argv.length) { const arg = _argv.shift()! console.log("parsing:", arg, _argv) - if (arg.startsWith("-")) { + const found = this.options.some((o) => o._isOption(arg)) + if (found) { console.log("option:", arg, _argv) _argv = this.parseOption(arg, _argv) continue @@ -121,15 +111,21 @@ export default class MassargCommand { } private parseOption(arg: string, argv: string[]): string[] { - const option = this.options.find( - (o) => (arg.startsWith("--") && o.name === arg.slice(2)) || o.aliases.includes(arg.slice(1)), - ) + const option = this.options.find((o) => o._match(arg)) + if (!option) { // TODO create custom error object throw new Error(`Unknown option ${arg}`) } const res = option.valueFromArgv([arg, ...argv]) - this.args[res.key as keyof Args] = res.value as Args[keyof Args] + console.log("option class name", option.constructor.name) + if (option.isArray) { + this.args[res.key as keyof Args] ??= [] as Args[keyof Args] + const _a = this.args[res.key as keyof Args] as unknown[] + _a.push(res.value) as Args[keyof Args] + } else { + this.args[res.key as keyof Args] = res.value as Args[keyof Args] + } console.log("option response:", { value: res.value, argv: res.argv }) return res.argv } @@ -139,6 +135,22 @@ export default class MassargCommand { console.log(argv) return {} as Args } + + 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") + } } export { MassargCommand } diff --git a/src/example.ts b/src/example.ts index 4d10209..c40e59b 100644 --- a/src/example.ts +++ b/src/example.ts @@ -1,37 +1,84 @@ import { massarg } from "." import MassargCommand from "./command" +import { ParseError } from "./error" type A = { test: boolean } const args = massarg({ name: "my-cli", description: "This is an example CLI", }) - .main((opts) => { - console.log("Main command - printing all opts") - console.log(opts) - }) + // .main((opts) => { + // console.log("Main command - printing all opts") + // console.log(opts) + // }) .command( - new MassargCommand({ - name: "command", - description: "Example command", - aliases: ["c"], + massarg<{ component: string }>({ + name: "add", + description: "Add a component", + aliases: ["a"], run: (opts) => { - console.log("`command` Command - printing all opts") - console.log(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: "command-option", - description: "Example command option", - aliases: ["o"], + name: "component", + description: "Component to remove", + aliases: ["c"], // aliases: "" as never, }), ) + .option({ + name: "bool", + description: "Example number option", + aliases: ["b"], + type: "boolean", + }) .option({ name: "number", description: "Example number option", aliases: ["n"], type: "number", - parse: (s) => parseFloat(s), }) const opts = args.getArgs(process.argv.slice(2)) diff --git a/src/massarg.ts b/src/massarg.ts index a95c24b..5893f5e 100644 --- a/src/massarg.ts +++ b/src/massarg.ts @@ -1,16 +1,18 @@ import MassargCommand, { ArgsObject, CommandConfig } from "./command" -type MinimalCommandConfig = Omit, "aliases" | "run"> +type MinimalCommandConfig = Omit, "aliases" | "run"> & + Partial, "aliases" | "run">> export default class Massarg extends MassargCommand { constructor(options: MinimalCommandConfig) { // TODO consider re-using name and description for general help, and pass them to super super({ - ...options, aliases: [], run: () => { - throw new Error("Massarg is not a command") + console.log(this.helpString()) + // throw new Error("No main command provided") }, + ...options, }) } } diff --git a/src/option.ts b/src/option.ts index a26d109..4708599 100644 --- a/src/option.ts +++ b/src/option.ts @@ -9,10 +9,32 @@ export const OptionConfig = (type: T) => defaultValue: z.any().optional(), aliases: z.string().array(), parse: z.function().args(z.string()).returns(type).optional(), + array: z.boolean().optional(), }) export type OptionConfig = z.infer>>> -export type OptionType = "string" | "number" | "boolean" +export const TypedOptionConfig = (type: T) => + OptionConfig(type).merge( + z.object({ + type: z.enum(["string", "number", "boolean"]).optional(), + }), + ) +export type TypedOptionConfig = z.infer>>> + +export const ArrayOptionConfig = (type: T) => + TypedOptionConfig(z.array(type)).merge( + z.object({ + defaultValue: z.array(type).optional(), + }), + ) +export type ArrayOptionConfig = z.infer>>> + +const OPT_FULL_PREFIX = "--" +const OPT_SHORT_PREFIX = "-" +const NEGATE_FULL_PREFIX = "no-" +const NEGATE_SHORT_PREFIX = "^" + +export type ArgvValue = { argv: string[]; value: T; key: string } export default class MassargOption { name: string @@ -20,6 +42,7 @@ export default class MassargOption { defaultValue?: T aliases: string[] parse: (value: string) => T + isArray: boolean constructor(options: OptionConfig) { OptionConfig(z.any()).parse(options) @@ -28,9 +51,20 @@ export default class MassargOption { this.defaultValue = options.defaultValue this.aliases = options.aliases this.parse = options.parse ?? ((x) => x as unknown as T) + this.isArray = options.array ?? false } - valueFromArgv(argv: string[]): { argv: string[]; value: T; key: string } { + static fromTypedConfig(config: TypedOptionConfig): MassargOption { + switch (config.type) { + case "number": + return new MassargNumber(config as OptionConfig) as MassargOption + case "boolean": + return new MassargFlag(config) as MassargOption + } + return new MassargOption(config as OptionConfig) + } + + valueFromArgv(argv: string[]): ArgvValue { // TODO: support --option=value argv.shift() try { @@ -47,6 +81,36 @@ export default class MassargOption { throw e } } + + helpString(): string { + const aliases = this.aliases.length ? `|${this.aliases.join("|-")}` : "" + return `--${this.name}${aliases} ${this.description}` + } + + _match(arg: string): boolean { + // full prefix + if (arg.startsWith(OPT_FULL_PREFIX)) { + // negate full prefix + if (arg.startsWith(`--${NEGATE_FULL_PREFIX}`)) { + return this.name === arg.slice(`--${NEGATE_FULL_PREFIX}`.length) + } + return this.name === arg.slice(OPT_FULL_PREFIX.length) + } + // short prefix + if (arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)) { + return this.aliases.includes(arg.slice(OPT_SHORT_PREFIX.length)) + } + // negate short prefix + if (arg.startsWith(NEGATE_SHORT_PREFIX)) { + return this.aliases.includes(arg.slice(NEGATE_SHORT_PREFIX.length)) + } + // no prefix + return false + } + + _isOption(arg: string): boolean { + return arg.startsWith(OPT_FULL_PREFIX) || arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX) + } } export class MassargNumber extends MassargOption { @@ -57,7 +121,7 @@ export class MassargNumber extends MassargOption { }) } - valueFromArgv(argv: string[]): { argv: string[]; value: number; key: string } { + valueFromArgv(argv: string[]): ArgvValue { try { const { argv: _argv, value } = super.valueFromArgv(argv) if (isNaN(value)) { @@ -89,9 +153,9 @@ export class MassargFlag extends MassargOption { }) } - valueFromArgv(argv: string[]): { argv: string[]; value: boolean; key: string } { + valueFromArgv(argv: string[]): ArgvValue { try { - const isNegation = argv[0]?.startsWith("-!") + const isNegation = argv[0]?.startsWith("^") argv.shift() if (isNegation) { return { key: this.name, value: false, argv } diff --git a/src/utils.ts b/src/utils.ts index 5c21d42..db72a65 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,67 @@ 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[], config?: GenerateTableOptions): string { + return generateHelpTable(options, { + namePrefix: "--", + aliasPrefix: "-", + ...config, + }) +} + +export function generateCommandsHelpTable(commands: MassargCommand[], config?: GenerateTableOptions): string { + return generateHelpTable(commands, { + namePrefix: "", + aliasPrefix: "", + ...config, + }) +}