feat: array & typed options

This commit is contained in:
2023-11-19 01:58:41 +02:00
committed by Chen Asraf
parent 7d5d3025c6
commit 73c1ad9591
5 changed files with 239 additions and 52 deletions

View File

@@ -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 = <RunArgs extends z.ZodType>(args: RunArgs) =>
z.object({
@@ -18,14 +18,16 @@ export type CommandConfig<T = unknown> = z.infer<ReturnType<typeof CommandConfig
export type ArgsObject = Record<string, unknown>
// export type RunFn<Args extends ArgsObject> = (options: Args) => Promise<void> | void
export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
name: string
description: string
private aliases: string[]
aliases: string[]
private _run?: (options: Args) => Promise<void> | void
private options: MassargOption[] = []
private commands: MassargCommand<any>[] = []
private args: Partial<Args> = {}
options: MassargOption[] = []
commands: MassargCommand<any>[] = []
args: Partial<Args> = {}
constructor(options: CommandConfig<Args>) {
CommandConfig(z.any()).parse(options)
@@ -35,9 +37,9 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
this._run = options.run
}
command(config: CommandConfig<Args>): MassargCommand<Args>
command(config: MassargCommand<Args>): MassargCommand<Args>
command(config: CommandConfig<Args> | MassargCommand<Args>): MassargCommand<Args> {
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args>
command<A extends ArgsObject = Args>(config: MassargCommand<A>): MassargCommand<Args>
command<A extends ArgsObject = Args>(config: CommandConfig<A> | MassargCommand<A>): MassargCommand<Args> {
try {
const command = config instanceof MassargCommand ? config : new MassargCommand(config)
this.commands.push(command)
@@ -55,23 +57,10 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
}
option<T = string>(config: MassargOption<T>): MassargCommand<Args>
option<T = string>(config: OptionConfig<T> & { type?: OptionType }): MassargCommand<Args>
option<T = string>(config: (OptionConfig<T> & { type?: OptionType }) | MassargOption<T>): MassargCommand<Args> {
const factory = () => {
if (!("type" in config)) {
return new MassargOption(config as OptionConfig<T>)
}
switch (config.type) {
case "string":
return new MassargOption<string>(config as OptionConfig<string>)
case "number":
return new MassargNumber(config as OptionConfig<number>)
case "boolean":
return new MassargFlag(config)
}
}
option<T = string>(config: TypedOptionConfig<T>): MassargCommand<Args>
option<T = string>(config: TypedOptionConfig<T> | MassargOption<T>): MassargCommand<Args> {
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<Args extends ArgsObject = ArgsObject> {
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<Args extends ArgsObject = ArgsObject> {
}
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<Args extends ArgsObject = ArgsObject> {
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 }

View File

@@ -1,37 +1,84 @@
import { massarg } from "."
import MassargCommand from "./command"
import { ParseError } from "./error"
type A = { test: boolean }
const args = massarg<A>({
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<A>({
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))

View File

@@ -1,16 +1,18 @@
import MassargCommand, { ArgsObject, CommandConfig } from "./command"
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run">
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run"> &
Partial<Pick<CommandConfig<Args>, "aliases" | "run">>
export default class Massarg<Args extends ArgsObject = ArgsObject> extends MassargCommand<Args> {
constructor(options: MinimalCommandConfig<Args>) {
// 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,
})
}
}

View File

@@ -9,10 +9,32 @@ export const OptionConfig = <T extends z.ZodType>(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<T = unknown> = z.infer<ReturnType<typeof OptionConfig<z.ZodType<T>>>>
export type OptionType = "string" | "number" | "boolean"
export const TypedOptionConfig = <T extends z.ZodType>(type: T) =>
OptionConfig(type).merge(
z.object({
type: z.enum(["string", "number", "boolean"]).optional(),
}),
)
export type TypedOptionConfig<T = unknown> = z.infer<ReturnType<typeof TypedOptionConfig<z.ZodType<T>>>>
export const ArrayOptionConfig = <T extends z.ZodType>(type: T) =>
TypedOptionConfig(z.array(type)).merge(
z.object({
defaultValue: z.array(type).optional(),
}),
)
export type ArrayOptionConfig<T = unknown> = z.infer<ReturnType<typeof ArrayOptionConfig<z.ZodType<T>>>>
const OPT_FULL_PREFIX = "--"
const OPT_SHORT_PREFIX = "-"
const NEGATE_FULL_PREFIX = "no-"
const NEGATE_SHORT_PREFIX = "^"
export type ArgvValue<T> = { argv: string[]; value: T; key: string }
export default class MassargOption<T = unknown> {
name: string
@@ -20,6 +42,7 @@ export default class MassargOption<T = unknown> {
defaultValue?: T
aliases: string[]
parse: (value: string) => T
isArray: boolean
constructor(options: OptionConfig<T>) {
OptionConfig(z.any()).parse(options)
@@ -28,9 +51,20 @@ export default class MassargOption<T = unknown> {
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<T = unknown>(config: TypedOptionConfig<T>): MassargOption<T> {
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>)
}
valueFromArgv(argv: string[]): ArgvValue<T> {
// TODO: support --option=value
argv.shift()
try {
@@ -47,6 +81,36 @@ export default class MassargOption<T = unknown> {
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<number> {
@@ -57,7 +121,7 @@ export class MassargNumber extends MassargOption<number> {
})
}
valueFromArgv(argv: string[]): { argv: string[]; value: number; key: string } {
valueFromArgv(argv: string[]): ArgvValue<number> {
try {
const { argv: _argv, value } = super.valueFromArgv(argv)
if (isNaN(value)) {
@@ -89,9 +153,9 @@ export class MassargFlag extends MassargOption<boolean> {
})
}
valueFromArgv(argv: string[]): { argv: string[]; value: boolean; key: string } {
valueFromArgv(argv: string[]): ArgvValue<boolean> {
try {
const isNegation = argv[0]?.startsWith("-!")
const isNegation = argv[0]?.startsWith("^")
argv.shift()
if (isNegation) {
return { key: this.name, value: false, argv }

View File

@@ -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<unknown>[], config?: GenerateTableOptions): string {
return generateHelpTable(options, {
namePrefix: "--",
aliasPrefix: "-",
...config,
})
}
export function generateCommandsHelpTable(commands: MassargCommand[], config?: GenerateTableOptions): string {
return generateHelpTable(commands, {
namePrefix: "",
aliasPrefix: "",
...config,
})
}