diff --git a/packages/argparse/src/argparse.test.ts b/packages/argparse/src/argparse.test.ts index f374630..fa18bad 100644 --- a/packages/argparse/src/argparse.test.ts +++ b/packages/argparse/src/argparse.test.ts @@ -1,17 +1,11 @@ -import {argparse, IArgsConfig} from './argparse' +import {argparse, arg, IArgsConfig} from './argparse' describe('argparse', () => { it('parses args', () => { const args = argparse({ - one: { - type: 'string', - required: true, - }, - two: { - type: 'number', - default: 1, - }, + one: arg('string', {required: true}), + two: arg('number', {default: 100}), four: { type: 'boolean', }, @@ -125,6 +119,29 @@ describe('argparse', () => { }) }) + describe('choices', () => { + it('can enforce typed choices', () => { + const parse = argparse({ + choice: arg('string', { + choices: ['a', 'b'], + }), + num: arg('number', { + choices: [1, 2], + }), + }) + expect(() => parse(['--choice', 'c'])).toThrowError(/one of: a, b$/) + expect(() => parse(['--num', '3'])).toThrowError(/must be one of: 1, 2$/) + expect(parse(['--choice', 'a', '--num', '1'])).toEqual({ + choice: 'a', + num: 1, + }) + expect(parse(['--choice', 'b', '--num', '2'])).toEqual({ + choice: 'b', + num: 2, + }) + }) + }) + describe('positional', () => { it('can be defined', () => { const parse = argparse({ diff --git a/packages/argparse/src/argparse.ts b/packages/argparse/src/argparse.ts index 1eed3a2..6e4eaba 100644 --- a/packages/argparse/src/argparse.ts +++ b/packages/argparse/src/argparse.ts @@ -8,15 +8,19 @@ export type TArgType = ? boolean : never -export interface IArgConfig { - type: T +export interface IArgParam { alias?: string description?: string default?: TArgType + choices?: Array> required?: boolean positional?: boolean } +export interface IArgConfig extends IArgParam { + type: T +} + export interface IArgsConfig { [arg: string]: IArgConfig } @@ -26,7 +30,13 @@ export type TArgs = { TArgType : never } -const iterate = (arr: T[]) => { +interface IIterator { + hasNext(): boolean + next(): T + peek(): T +} + +const iterate = (arr: T[]): IIterator => { let i = -1 return { hasNext() { @@ -58,6 +68,88 @@ function getDefaultValue(type: TArgTypeName) { } } +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 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(config: IArgsConfig) { + return Object.keys(config).map(argument => { + const argConfig = config[argument] + const {alias, description, 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) + } + const sample = samples.length ? ` (${samples.join(', ')})` : '' + return padRight(name + ' ' + type, 20) + ' ' + description + sample + }) + .join('\n') +} + +export function arg( + type: T, + config: IArgParam = {}, +): IArgConfig { + return { + ...config, + type, + } +} + export const argparse = ( config: T, ) => (args: string[]): TArgs => { @@ -66,22 +158,24 @@ export const argparse = ( const aliases: Record = {} const positional: string[] = [] - const requiredArgs = Object.keys(config).reduce((obj, arg) => { - const argConfig = config[arg] - result[arg] = argConfig.default !== undefined + 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] = arg + aliases[argConfig.alias] = argument } if (argConfig.positional) { - positional.push(arg) + positional.push(argument) } if (argConfig.required) { - obj[arg] = true + obj[argument] = true } return obj }, {} as Record) @@ -90,12 +184,12 @@ export const argparse = ( return nameOrAlias in config ? nameOrAlias : aliases[nameOrAlias] } - function processFlags(arg: string): string { - if (arg.substring(1, 2) === '-') { - return arg.substring(2) + function processFlags(argument: string): string { + if (argument.substring(1, 2) === '-') { + return argument.substring(2) } - const flags = arg.substring(1).split('') + const flags = argument.substring(1).split('') flags.slice(0, flags.length - 1) .forEach(flag => { @@ -120,53 +214,32 @@ export const argparse = ( } while (it.hasNext()) { - const arg = it.next() - const isPositional = arg.substring(0, 1) !== '-' + const argument = it.next() + const isPositional = argument.substring(0, 1) !== '-' const argName = !isPositional - ? processFlags(arg) + ? processFlags(argument) : getNextPositional() const argConfig = config[argName] - assert(!!argConfig, 'Unknown argument: ' + arg) + assert(!!argConfig, 'Unknown argument: ' + argument) delete requiredArgs[argName] - const peek = it.peek() switch (argConfig.type) { case 'string': - if (isPositional) { - result[argName] = arg - } else { - assert(it.hasNext(), 'Value of argument must be a string: ' + arg) - result[argName] = it.next() - } - continue + result[argName] = getValue(it, argument, isPositional) + assert(!!result[argName], + 'Value of argument must be a string: ' + argument) + break case 'number': - const num = parseInt(isPositional ? arg : it.next(), 10) - assert(!isNaN(num), 'Value of argument must be a number: ' + arg) + const num = parseInt(getValue(it, argument, isPositional), 10) + assert(!isNaN(num), 'Value of argument must be a number: ' + argument) result[argName] = num - continue + break case 'boolean': - if (isPositional) { - if (arg === 'true') { - result[argName] = true - } else if (arg === 'false') { - result[argName] = false - } else { - assert(false, 'Value of argument must be true or false: ' + arg) - } - continue - } - if (peek === 'true') { - it.next() - result[argName] = true - } else if (peek === 'false') { - it.next() - result[argName] = false - } else { - result[argName] = true - } - continue + 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: ' +