357 lines
9.1 KiB
TypeScript
357 lines
9.1 KiB
TypeScript
export type TArgTypeName = 'string' | 'string[]' | 'number' | 'boolean'
|
|
export type TArgType<T extends TArgTypeName> =
|
|
T extends 'string'
|
|
? string
|
|
: T extends 'string[]'
|
|
? string[]
|
|
: T extends 'number'
|
|
? number
|
|
: T extends 'boolean'
|
|
? boolean
|
|
: never
|
|
|
|
export const N_ONE_OR_MORE = '+'
|
|
export const N_ZERO_OR_MORE = '*'
|
|
export const N_DEFAULT_VALUE = 1
|
|
|
|
export type TNumberOfArgs = number | '+' | '*'
|
|
|
|
export interface IArgParam<T extends TArgTypeName> {
|
|
alias?: string
|
|
description?: string
|
|
default?: TArgType<T>
|
|
choices?: Array<TArgType<T>>
|
|
required?: boolean
|
|
positional?: boolean
|
|
n?: TNumberOfArgs
|
|
}
|
|
|
|
export interface IArgument<T extends TArgTypeName> extends IArgParam<T> {
|
|
type: T
|
|
}
|
|
|
|
export interface IArgsConfig {
|
|
[arg: string]: IArgument<TArgTypeName>
|
|
}
|
|
|
|
export type TArgs<T extends IArgsConfig> = {
|
|
[k in keyof T]: T[k] extends IArgument<infer A> ?
|
|
TArgType<A> : never
|
|
}
|
|
|
|
interface IIterator<T> {
|
|
hasNext(): boolean
|
|
next(): T
|
|
peek(): T
|
|
}
|
|
|
|
const iterate = <T>(arr: T[]): IIterator<T> => {
|
|
let i = -1
|
|
return {
|
|
hasNext() {
|
|
return i < arr.length - 1
|
|
},
|
|
next(): T {
|
|
return arr[++i]
|
|
},
|
|
peek(): T {
|
|
return arr[i + 1]
|
|
},
|
|
}
|
|
}
|
|
|
|
function createError(message: string) {
|
|
return new Error('Error parsing arguments: ' + message)
|
|
}
|
|
|
|
function assert(cond: boolean, message: string) {
|
|
if (!cond) {
|
|
throw createError(message)
|
|
}
|
|
}
|
|
|
|
function getDefaultValue(type: TArgTypeName) {
|
|
switch (type) {
|
|
case 'number':
|
|
return NaN
|
|
case 'string':
|
|
return ''
|
|
case 'boolean':
|
|
return false
|
|
case 'string[]':
|
|
return [] as string[]
|
|
}
|
|
}
|
|
|
|
function getBooleanValue(
|
|
it: IIterator<string>,
|
|
argument: string,
|
|
isPositional: boolean,
|
|
): boolean {
|
|
if (isPositional) {
|
|
if (argument === 'true') {
|
|
return true
|
|
} else if (argument === 'false') {
|
|
return false
|
|
} else {
|
|
throw new Error('Value of argument must be true or false: ' + arg)
|
|
}
|
|
}
|
|
const peek = it.peek()
|
|
if (peek === 'true') {
|
|
it.next()
|
|
return true
|
|
} else if (peek === 'false') {
|
|
it.next()
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
function getValue(
|
|
it: IIterator<string>,
|
|
argument: string,
|
|
isPositional: boolean,
|
|
): string {
|
|
return isPositional ? argument : it.next()
|
|
}
|
|
|
|
function extractArray(
|
|
it: IIterator<string>,
|
|
argument: string,
|
|
isPositional: boolean,
|
|
n: TNumberOfArgs = N_DEFAULT_VALUE,
|
|
): string[] {
|
|
function getLimit() {
|
|
const l = typeof n === 'number' ? n : Infinity
|
|
return isPositional ? l - 1 : l
|
|
}
|
|
const limit = getLimit()
|
|
const array = isPositional ? [argument] : []
|
|
let i = 0
|
|
for (; i < limit && it.hasNext(); i++) {
|
|
array.push(it.next())
|
|
}
|
|
if (typeof n === 'number') {
|
|
assert(i === limit,
|
|
`Expected ${limit} arguments for ${argument}, but got ${i}`)
|
|
}
|
|
return array
|
|
}
|
|
|
|
function checkChoice<T>(argument: string, choice: T, choices?: T[]) {
|
|
if (choices) {
|
|
assert(
|
|
choices.some(c => choice === c),
|
|
`Argument "${argument}" must be one of: ${choices.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
export function padRight(str: string, chars: number) {
|
|
while (str.length < chars) {
|
|
str += ' '
|
|
}
|
|
return str
|
|
}
|
|
|
|
export function help(command: string, config: IArgsConfig) {
|
|
const keys = Object.keys(config)
|
|
|
|
function getArrayHelp(
|
|
k: string,
|
|
required?: boolean,
|
|
n: TNumberOfArgs = N_DEFAULT_VALUE,
|
|
) {
|
|
k = k.toUpperCase()
|
|
if (n === N_ZERO_OR_MORE) {
|
|
return `[${k}...]`
|
|
}
|
|
if (n === N_ONE_OR_MORE) {
|
|
return required ? `${k}...` : `[${k}...]`
|
|
}
|
|
if (n === 1) {
|
|
return required ? k : `[${k}]`
|
|
}
|
|
|
|
const limit: number = n
|
|
const array = []
|
|
for (let i = 0; i < limit; i++) {
|
|
array.push(k + (i + 1))
|
|
}
|
|
return required ? array.join(' ') : `[${array.join(' ')}]`
|
|
}
|
|
|
|
const positionalHelp = [
|
|
command,
|
|
'[OPTIONS]',
|
|
keys
|
|
.filter(k => config[k].positional)
|
|
.map(k => getArrayHelp(k, config[k].required, config[k].n))
|
|
.join(' '),
|
|
].join(' ')
|
|
|
|
const argsHelp = 'Options:\n' + keys.map(argument => {
|
|
const argConfig = config[argument]
|
|
const {alias, type} = argConfig
|
|
const name = alias
|
|
? `-${alias}, --${argument}`
|
|
: ` --${argument}`
|
|
const samples = []
|
|
if (argConfig.required) {
|
|
samples.push('required')
|
|
}
|
|
if (argConfig.default) {
|
|
samples.push('default: ' + argConfig.default)
|
|
}
|
|
if (argConfig.choices) {
|
|
samples.push('choices: ' + argConfig.choices.join(','))
|
|
}
|
|
const description = argConfig.description
|
|
? ' ' + argConfig.description : ''
|
|
const sample = samples.length ? ` (${samples.join(', ')})` : ''
|
|
const argType = type === 'string[]'
|
|
? getArrayHelp(argument, argConfig.required, argConfig.n)
|
|
: type
|
|
return padRight(name + ' ' + argType, 30) + ' ' + description + sample
|
|
})
|
|
.join('\n')
|
|
|
|
return [positionalHelp, argsHelp]
|
|
.filter(h => h.length)
|
|
.join('\n\n')
|
|
}
|
|
|
|
export function arg<T extends TArgTypeName>(
|
|
type: T,
|
|
config: IArgParam<T> = {},
|
|
): IArgument<T> {
|
|
return {
|
|
...config,
|
|
type,
|
|
}
|
|
}
|
|
|
|
export function argparse<T extends IArgsConfig>(
|
|
config: T,
|
|
exit: () => void = () => process.exit(),
|
|
/* tslint:disable-next-line */
|
|
log: (message: string) => void = console.log.bind(console),
|
|
) {
|
|
return {
|
|
parse(args: string[]): TArgs<T> {
|
|
const command = args[0]
|
|
args = args.slice(1)
|
|
const result: any = {}
|
|
const it = iterate(args)
|
|
|
|
const aliases: Record<string, string> = {}
|
|
const positional: string[] = []
|
|
const requiredArgs = Object.keys(config).reduce((obj, argument) => {
|
|
const argConfig = config[argument]
|
|
result[argument] = argConfig.default !== undefined
|
|
? argConfig.default
|
|
: getDefaultValue(argConfig.type)
|
|
if (argConfig.alias) {
|
|
assert(argConfig.alias.length === 1,
|
|
'Alias must be a single character: ' + argConfig.alias)
|
|
assert(
|
|
argConfig.alias in aliases === false,
|
|
'Duplicate alias: ' + argConfig.alias)
|
|
aliases[argConfig.alias] = argument
|
|
}
|
|
if (argConfig.positional) {
|
|
positional.push(argument)
|
|
}
|
|
if (argConfig.required) {
|
|
obj[argument] = true
|
|
}
|
|
return obj
|
|
}, {} as Record<string, true>)
|
|
|
|
function getArgumentName(nameOrAlias: string): string {
|
|
return nameOrAlias in config ? nameOrAlias : aliases[nameOrAlias]
|
|
}
|
|
|
|
function processFlags(argument: string): string {
|
|
if (argument.substring(1, 2) === '-') {
|
|
return argument.substring(2)
|
|
}
|
|
|
|
const flags = argument.substring(1).split('')
|
|
|
|
flags.slice(0, flags.length - 1)
|
|
.forEach(flag => {
|
|
const argName = getArgumentName(flag)
|
|
const argConfig = config[argName]
|
|
assert(!!argConfig, 'Unknown argument: ' + flag)
|
|
assert(argConfig.type === 'boolean',
|
|
'The argument is not a flag/boolean: ' + flag)
|
|
delete requiredArgs[argName]
|
|
result[argName] = true
|
|
})
|
|
|
|
const lastArgName = getArgumentName(flags[flags.length - 1])
|
|
assert(!!lastArgName, 'Unknown argument: ' + lastArgName)
|
|
return lastArgName
|
|
}
|
|
|
|
function getNextPositional(): string {
|
|
const p = positional.shift()
|
|
assert(!!p, 'No defined positional arguments')
|
|
return p!
|
|
}
|
|
|
|
let onlyPositionals = false
|
|
while (it.hasNext()) {
|
|
const argument = it.next()
|
|
if (argument === '--' && !onlyPositionals) {
|
|
onlyPositionals = true
|
|
continue
|
|
}
|
|
const isPositional = argument.substring(0, 1) !== '-' || onlyPositionals
|
|
const argName = !isPositional
|
|
? processFlags(argument)
|
|
: getNextPositional()
|
|
const argConfig = config[argName]
|
|
if (!isPositional && argName === 'help') {
|
|
log(help(command, config))
|
|
exit()
|
|
// should never reach this in real life
|
|
return null as any
|
|
}
|
|
assert(!!argConfig, 'Unknown argument: ' + argument)
|
|
delete requiredArgs[argName]
|
|
switch (argConfig.type) {
|
|
case 'string':
|
|
result[argName] = getValue(it, argument, isPositional)
|
|
assert(!!result[argName],
|
|
'Value of argument must be a string: ' + argument)
|
|
break
|
|
case 'string[]':
|
|
result[argName] = extractArray(
|
|
it, argument, isPositional, argConfig.n)
|
|
break
|
|
case 'number':
|
|
const num = parseInt(getValue(it, argument, isPositional), 10)
|
|
assert(!isNaN(num),
|
|
'Value of argument must be a number: ' + argument)
|
|
result[argName] = num
|
|
break
|
|
case 'boolean':
|
|
result[argName] = getBooleanValue(it, argument, isPositional)
|
|
break
|
|
default:
|
|
assert(false, 'Unknown type: ' + argConfig.type)
|
|
}
|
|
checkChoice(argument, result[argName], argConfig.choices)
|
|
}
|
|
|
|
assert(!Object.keys(requiredArgs).length, 'Missing required args: ' +
|
|
Object.keys(requiredArgs).join(', '))
|
|
|
|
return result
|
|
},
|
|
}
|
|
}
|