Files
massarg/src/command.ts
2024-01-31 09:55:44 +02:00

544 lines
17 KiB
TypeScript

import { z } from 'zod'
import { isZodError, ParseError, ValidationError } from './error'
import { defaultHelpConfig, HelpConfig, HelpGenerator } from './help'
import {
MassargOption,
MassargFlag,
TypedOptionConfig,
MassargHelpFlag,
OPT_FULL_PREFIX,
NEGATE_FULL_PREFIX,
OPT_SHORT_PREFIX,
NEGATE_SHORT_PREFIX,
Prefixes,
FlagConfig,
} from './option'
import { DeepRequired, setOrPush, deepMerge, getErrorMessage, capitalize } from './utils'
import { MassargExample, ExampleConfig } from './example'
import { format } from './style'
export const CommandConfig = <RunArgs extends ArgsObject = ArgsObject>(args: z.ZodType<RunArgs>) =>
z.object({
/** Command name */
name: z.string(),
/** Command description, displayed in the help output */
description: z.string(),
/** Command aliases */
aliases: z.string().array().optional(),
/**
* Function used when invoking this command. It receives the parsed options and the primary
* instance of Massarg used to invoke this command (the top-level instance)
*/
run: z
.function()
.args(args, z.any())
.returns(z.union([z.promise(z.void()), z.void()])) as z.ZodType<Runner<RunArgs>>,
/** Prefix of options understood by this command */
optionPrefix: z.string().default(OPT_FULL_PREFIX).optional(),
/** Prefix of negated flags understood by this command */
negateFlagPrefix: z.string().default(NEGATE_FULL_PREFIX).optional(),
/** Prefix of aliases of options understood by this command */
optionAliasPrefix: z.string().default(OPT_SHORT_PREFIX).optional(),
/** Prefix of aliases of negated flags understood by this command */
negateAliasPrefix: z.string().default(NEGATE_SHORT_PREFIX).optional(),
})
export type CommandConfig<RunArgs extends ArgsObject = ArgsObject> = z.infer<
ReturnType<typeof CommandConfig<RunArgs>>
>
export type ArgsObject = Record<string | number | symbol, any>
export type Runner<Args extends ArgsObject> = (
options: Args,
instance: MassargCommand<Args>,
) => Promise<void> | void
/**
* A command is a named function that can be invoked with a set of options.
*
* Commands can have sub-commands, which can have their own sub-commands, and so on.
*
* Options are not inherited by sub-commands, but their parsed values are passed down when
* invoking a sub-command. This works recursively.
*
* @example
* ```ts
* massarg(options).command({
* name: 'foo',
* description: 'foo command',
* run: (options, instance) => {
* console.log(options, instance)
* },
* })
* ```
*/
export class MassargCommand<Args extends ArgsObject = ArgsObject>
implements Omit<CommandConfig<Args>, 'run'>
{
name: string
description: string
aliases: string[]
private _run?: Runner<Args>
commands: MassargCommand<any>[] = []
options: MassargOption[] = []
examples: MassargExample[] = []
args: Partial<Args> = {}
private _helpConfig: HelpConfig
parent?: MassargCommand<any>
optionPrefix = OPT_FULL_PREFIX
negateFlagPrefix = NEGATE_FULL_PREFIX
optionAliasPrefix = OPT_SHORT_PREFIX
negateAliasPrefix = NEGATE_SHORT_PREFIX
constructor(options: CommandConfig<Args>, parent?: MassargCommand<any>) {
CommandConfig(z.any()).parse(options)
this.name = options.name
this.description = options.description
this.aliases = options.aliases ?? []
this._run = options.run
this._helpConfig = {}
this.parent = parent
// TODO mix these with help config
this.optionPrefix = options.optionPrefix ?? this.optionPrefix
this.negateFlagPrefix = options.negateFlagPrefix ?? this.negateFlagPrefix
this.optionAliasPrefix = options.optionAliasPrefix ?? this.optionAliasPrefix
this.negateAliasPrefix = options.negateAliasPrefix ?? this.negateAliasPrefix
}
get optionPrefixes(): Prefixes {
return this.getPrefixes()
}
private getPrefixes(): Prefixes {
return {
optionPrefix: this.optionPrefix,
aliasPrefix: this.optionAliasPrefix,
negateFlagPrefix: this.negateFlagPrefix,
negateAliasPrefix: this.negateAliasPrefix,
}
}
get helpConfig(): DeepRequired<HelpConfig> {
if (this.parent) {
return deepMerge(this.parent.helpConfig, this._helpConfig)
}
return deepMerge(
defaultHelpConfig,
deepMerge(
{
optionOptions: {
namePrefix: this.optionPrefix,
aliasPrefix: this.optionAliasPrefix,
negatePrefix: this.negateFlagPrefix,
negateAliasPrefix: this.negateAliasPrefix,
},
} as Partial<HelpConfig>,
this._helpConfig,
),
) as Required<HelpConfig>
}
/**
* Add a sub-command to this command.
*
* The sub-command will inherit all help configuration from the parent commands,
* all the way up to the top-level command.
*
* While options are not inherited, they will be passed from any parent commands
* to the sub-command when invoked.
*/
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args & A>
command<A extends ArgsObject = Args>(config: MassargCommand<A>): MassargCommand<Args & A>
command<A extends ArgsObject = Args>(
config: CommandConfig<A> | MassargCommand<A>,
): MassargCommand<Args & A> {
try {
const command = config instanceof MassargCommand ? config : new MassargCommand(config)
const existing = this.commands.find((c) => c.name === command.name)
if (existing) {
throw new ValidationError({
code: 'duplicate_command',
message: `Command "${command.name}" already exists`,
path: [this.name, command.name],
})
}
command.parent = this
this.commands.push(command)
return this as unknown as MassargCommand<Args & A>
} catch (e) {
if (isZodError(e)) {
e = 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,
})
}
this.printError(e)
throw e
}
}
/**
* Adds a flag to this command.
*
* A flag is an option that is either present or not. It can be used to toggle
* a boolean value, or to indicate that a command should be run in a different
* mode.
*
* A flag can be negated by prefixing it with `no-`. For example, `--no-verbose`,
* or by prefixing the alias with `^` instead of `-`. This is configurable via the command's
* configuration.
*/
flag(config: FlagConfig): MassargCommand<Args>
flag(config: MassargFlag): MassargCommand<Args>
flag(config: FlagConfig | MassargFlag): MassargCommand<Args> {
try {
const flag = config instanceof MassargFlag ? config : new MassargFlag(config)
this.assertNotDuplicate(flag, 'flag')
this.options.push(flag as MassargOption)
return this
} catch (e) {
if (isZodError(e)) {
e = 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,
})
}
this.printError(e)
throw e
}
}
/**
* Adds an option to this command.
*
* An option is a named value that can be passed to a command. It can be
* required or optional, and can be of any type.
*
* You can specify a default value for an option, which will be used if the
* option is not passed to the command.
*
* You can also specify a parse function, which will be used to parse the
* value passed to the command. This is useful if you want to parse a string
* into a more complex type, or if you want to validate the value.
*/
option<T = string, A extends ArgsObject = Args>(config: MassargOption<T, A>): MassargCommand<Args>
option<T = string, A extends ArgsObject = Args>(
config: TypedOptionConfig<T, A>,
): MassargCommand<Args>
option<T = string, A extends ArgsObject = Args>(
config: TypedOptionConfig<T, A> | MassargOption<T, A>,
): MassargCommand<Args> {
try {
const option =
config instanceof MassargOption
? config
: MassargOption.fromTypedConfig(config as TypedOptionConfig<T, A>)
this.assertNotDuplicate(option, 'option')
this.assertOnlyOneDefault<T, A>(option)
this.options.push(option as MassargOption)
return this
} catch (e) {
if (isZodError(e)) {
e = 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,
})
}
this.printError(e)
throw e
}
}
private assertNotDuplicate<T = string, A extends ArgsObject = Args>(
option: MassargOption<T, A>,
type: 'option' | 'flag',
) {
const existingName = this.options.find((c) => c.name === option.name)
if (existingName) {
throw new ValidationError({
code: `duplicate_${type}_name`,
message: `${capitalize(type)} name "${existingName.name}" already exists`,
path: [this.name, option.name],
})
}
const existingAlias = this.options.find((c) =>
c.aliases.some((a) => option.aliases.includes(a)),
)
if (existingAlias) {
const alias = option.aliases.find((a) => existingAlias.aliases.includes(a))!
throw new ValidationError({
code: 'duplicate_option_alias',
message: `Option alias "${alias}" already exists on option "${existingAlias.name}"`,
path: [this.name, option.name],
})
}
}
private assertOnlyOneDefault<T = string, A extends ArgsObject = Args>(
option: MassargOption<T, A>,
) {
if (option.isDefault) {
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
throw new ValidationError({
code: 'duplicate_default_option',
message: `Option "${option.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`,
path: [this.name, option.name],
})
}
}
}
/**
* Adds an example to this command.
*
* An example is a description of how to use the command, with an example input and output.
*
* At least one of `description`, `input` or `output` must be provided, but neither alone is
* required.
*/
example(config: ExampleConfig): MassargCommand<Args> {
this.examples.push(new MassargExample(config))
return this
}
/**
* Configure the help output for this (and all child) commands.
*
* You can automatically bind the help command to this command, and/or bind the help option
* to this command.
*
* If you don't opt-in to this behavior with `bindCommand` or `bindOption`, you can still
* access the help output via `this.helpString()` and `this.printHelp()`.
*/
help(config: HelpConfig): MassargCommand<Args> {
this._helpConfig = HelpConfig.parse(config)
let ret: MassargCommand<any> = this
if (this.helpConfig.bindCommand) {
ret = ret.command(new MassargHelpCommand())
}
if (this.helpConfig.bindOption) {
ret = ret.option(new MassargHelpFlag())
}
return this as MassargCommand<Args>
}
/**
* Configure the main function for this command. This command will run when no sub-commands
* are provided.
*
* If none is provided, help will be printed.
*/
main(run: Runner<Args>): MassargCommand<Args> {
this._run = run
return this
}
/**
* Parse the given arguments and run the command or sub-commands along with the given options
* and flags.
*
* To parse the arguments without running any commands and only get the output args,
* use `getArgs` instead.
*/
parse(
argv = process.argv.slice(2),
args?: Partial<Args>,
parent?: MassargCommand<Args>,
): Promise<void> | void {
try {
this.getArgs(argv, args, parent, true)
} catch (e) {
this.printError(e)
}
}
private printError(e: unknown) {
const message = getErrorMessage(e)
console.error(format(message, { color: 'red' }))
}
private parseOption(arg: string, argv: string[]) {
const prefixes = { ...this.optionPrefixes }
const option = this.options.find((o) => o.isMatch(arg, prefixes))
if (!option) {
throw new ValidationError({
path: [MassargOption.findNameInArg(arg, prefixes)],
code: 'unknown_option',
message: 'Unknown option',
})
}
const res = option.parseDetails([arg, ...argv], { ...this.args }, prefixes)
this.args[res.key as keyof Args] = setOrPush<Args[keyof Args]>(
res.value,
this.args[res.key as keyof Args],
option.isArray,
)
return res.argv
}
/** Parse the given arguments and return the output args. */
getArgs(
argv: string[],
__args?: Partial<Args>,
parent?: MassargCommand<any>,
parseCommands?: false,
): Promise<void> | void
getArgs(
argv: string[],
__args?: Partial<Args>,
parent?: MassargCommand<any>,
parseCommands?: true,
): Args
getArgs(
argv: string[],
args?: Partial<Args>,
parent?: MassargCommand<any>,
parseCommands = false,
): Args | Promise<void> | void {
try {
let _args: Args = { ...this.args, ...args } as Args
let _argv = [...argv]
const _a = this.args as Record<string, any>
// fill defaults
for (const option of this.options) {
if (option.defaultValue !== undefined && _a[option.name] === undefined) {
_args[option.getOutputName() as keyof Args] = option.defaultValue as Args[keyof Args]
}
}
// parse options
while (_argv.length) {
const arg = _argv.shift()!
// make sure option exists
const found = this.options.find((o) => o.isMatch(arg, this.optionPrefixes))
if (found) {
if (this.helpConfig.bindOption && found.name === 'help') {
if (parseCommands) {
this.printHelp()
return
}
return this.args as Args
}
_argv = this.parseOption(arg, _argv)
_args = { ..._args, ...this.args }
continue
}
// if not, try see if it's a command
const command = this.commands.find((c) => c.name === arg || c.aliases.includes(arg))
if (command) {
// this is dry run, just exit
if (!parseCommands) {
return command.getArgs(_argv, this.args, parent ?? this, false)
// break
}
// this is real run, parse command, pass unparsed args
return command.parse(_argv, this.args, parent ?? this)
}
// default option - passes arg value even without flag name
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
this.parseOption(`--${defaultOption.name}`, [arg])
continue
}
// not parsed by any step, add to extra key
_a.extra ??= []
_a.extra.push(arg)
}
// merge args
this.args = { ...this.args, ..._args }
this.assertRequired()
// dry run, just exit
if (!parseCommands) {
return this.args as Args
}
// no sub command found, run main command
if (this._run) {
this._run(this.args as Args, parent ?? this)
}
} catch (e) {
if (isZodError(e)) {
e = new ValidationError({
path: [this.name, ...e.issues[0].path.map((p) => p.toString())],
code: e.issues[0].code,
message: e.issues[0].message,
})
}
throw e
}
}
private assertRequired() {
const required = this.options.filter((o) => o.isRequired)
const missing = required.filter((o) => this.args[o.getOutputName() as keyof Args] === undefined)
if (missing.length) {
const plural = missing.length > 1 ? 's' : ''
throw new ValidationError({
code: 'missing_required_options',
message: `Missing required option${plural}: ${missing.map((o) => o.name).join(', ')}`,
path: [this.name],
})
}
}
/**
* Generate the help output for this command, and return it as a string.
*/
helpString(): string {
return new HelpGenerator(this).generate()
}
/**
* Print the help output for this command.
*/
printHelp(): void {
console.log(this.helpString())
}
}
export class MassargHelpCommand<
T extends { command?: string } = { command?: string },
> extends MassargCommand<T> {
constructor(config: Partial<Omit<CommandConfig<T>, 'run'>> = {}) {
const _config = CommandConfig(z.any()).parse({
name: 'help',
aliases: ['h'],
description: 'Print help for this command, or a sub-command if specified',
run: (args: { command?: string }, parent) => {
if (args.command) {
const command = parent.commands.find((c) => c.name === args.command)
if (command) {
command.printHelp()
return
} else {
throw new ParseError({
path: ['command'],
code: 'unknown_command',
message: 'Unknown command',
received: args.command,
})
}
}
parent.printHelp()
},
...config,
} as CommandConfig<T>)
super(_config)
this.option({
name: 'command',
aliases: ['c'],
description: 'Command to print help for',
isDefault: true,
})
}
}