export type TArgTypeName = 'string' | 'string[]' | 'number' | 'boolean' export type TArgType = 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 { alias?: string description?: string default?: TArgType choices?: Array> required?: boolean positional?: boolean n?: TNumberOfArgs } export interface IArgument extends IArgParam { type: T } export interface IArgsConfig { [arg: string]: IArgument } export type TArgs = { [k in keyof T]: T[k] extends IArgument ? TArgType : never } interface IIterator { hasNext(): boolean next(): T peek(): T } const iterate = (arr: T[]): IIterator => { 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, 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, argument: string, isPositional: boolean, ): string { return isPositional ? argument : it.next() } function extractArray( it: IIterator, 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(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( type: T, config: IArgParam = {}, ): IArgument { return { ...config, type, } } export function argparse( config: T, exit: () => void = () => process.exit(), /* tslint:disable-next-line */ log: (message: string) => void = console.log.bind(console), ) { return { parse(args: string[]): TArgs { const command = args[0] args = args.slice(1) const result: any = {} const it = iterate(args) const aliases: Record = {} 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) 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 }, } }