diff --git a/packages/argparse/src/argparse.test.ts b/packages/argparse/src/argparse.test.ts index 11d7eec..07fd90f 100644 --- a/packages/argparse/src/argparse.test.ts +++ b/packages/argparse/src/argparse.test.ts @@ -142,6 +142,98 @@ describe('argparse', () => { }) }) + describe('string[] and n', () => { + it('has a value of n = 1 by default', () => { + const {parse, help} = argparse({ + value: { + type: 'string[]', + }, + }) + expect(parse([]).value).toEqual([]) + expect(parse(['--value', 'one']).value).toEqual(['one']) + expect(help()).toEqual([ + '[OPTIONS] ', + '', + 'Options:', + ' --value [VALUE] ', + ].join('\n')) + }) + it('can be used to extract finite number of values', () => { + const {parse, help} = argparse({ + value: { + type: 'string[]', + n: 3, + }, + other: { + type: 'number', + alias: 'o', + }, + }) + expect(parse([]).value).toEqual([]) + expect(parse(['--value', 'a', 'b', '--other', '-o', '3'])).toEqual({ + value: ['a', 'b', '--other'], + other: 3, + }) + expect(help()).toEqual([ + '[OPTIONS] ', + '', + 'Options:', + ' --value [VALUE1 VALUE2 VALUE3] ', + '-o, --other number ', + ].join('\n')) + }) + it('can be used to collect any remaining arguments when n = "+"', () => { + const {parse, help} = argparse({ + value: arg('string[]', {n: '+', required: true}), + other: arg('number'), + }) + expect(() => parse([])).toThrowError(/Missing required args: value/) + expect(parse(['--value', 'a', '--other', '3'])).toEqual({ + value: ['a', '--other', '3'], + other: NaN, + }) + expect(parse(['--other', '2', '--value', 'a', '--other', '3'])).toEqual({ + value: ['a', '--other', '3'], + other: 2, + }) + expect(help()).toEqual([ + '[OPTIONS] ', + '', + 'Options:', + ' --value VALUE... (required)', + ' --other number ', + ].join('\n')) + }) + it('can collect remaining positional arguments when n = "*"', () => { + const {parse, help} = argparse({ + value: arg('string[]', {n: '*', required: true, positional: true}), + other: arg('number'), + }) + expect(parse(['a', 'b']).value).toEqual(['a', 'b']) + expect(() => parse(['--other', '3']).value) + .toThrowError(/Missing.*: value/) + expect(parse(['--other', '2', '--', '--other', '3'])).toEqual({ + value: ['--other', '3'], + other: 2, + }) + expect(parse(['--', '--other', '3'])).toEqual({ + value: ['--other', '3'], + other: NaN, + }) + expect(parse(['--other', '3', 'a', 'b', 'c'])).toEqual({ + value: ['a', 'b', 'c'], + other: 3, + }) + expect(help()).toEqual([ + '[OPTIONS] [VALUE...]', + '', + 'Options:', + ' --value [VALUE...] (required)', + ' --other number ', + ].join('\n')) + }) + }) + describe('positional', () => { it('can be defined', () => { const {parse} = argparse({ diff --git a/packages/argparse/src/argparse.ts b/packages/argparse/src/argparse.ts index f79b18d..b22be79 100644 --- a/packages/argparse/src/argparse.ts +++ b/packages/argparse/src/argparse.ts @@ -1,13 +1,21 @@ -export type TArgTypeName = 'string' | 'number' | 'boolean' +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 let exit = () => process.exit() export interface IArgParam { @@ -17,18 +25,19 @@ export interface IArgParam { choices?: Array> required?: boolean positional?: boolean + n?: TNumberOfArgs } -export interface IArgConfig extends IArgParam { +export interface IArgument extends IArgParam { type: T } export interface IArgsConfig { - [arg: string]: IArgConfig + [arg: string]: IArgument } export type TArgs = { - [k in keyof T]: T[k] extends IArgConfig ? + [k in keyof T]: T[k] extends IArgument ? TArgType : never } @@ -71,6 +80,8 @@ function getDefaultValue(type: TArgTypeName) { return '' case 'boolean': return false + case 'string[]': + return [] as string[] } } @@ -108,6 +119,29 @@ function getValue( 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 +} + export function isHelp(argv: string[]) { return argv.some(a => /^(-h|--help)$/.test(a)) } @@ -130,14 +164,35 @@ export function padRight(str: string, chars: number) { export function help(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 = [ '[OPTIONS]', keys .filter(k => config[k].positional) - .map(k => config[k].required - ? `${k.toUpperCase()}` - : `[${k.toUpperCase()}]`, - ) + .map(k => getArrayHelp(k, config[k].required, config[k].n)) .join(' '), ].join(' ') @@ -160,7 +215,10 @@ export function help(config: IArgsConfig) { const description = argConfig.description ? ' ' + argConfig.description : '' const sample = samples.length ? ` (${samples.join(', ')})` : '' - return padRight(name + ' ' + type, 30) + ' ' + description + sample + const argType = type === 'string[]' + ? getArrayHelp(argument, argConfig.required, argConfig.n) + : type + return padRight(name + ' ' + argType, 30) + ' ' + description + sample }) .join('\n') @@ -172,7 +230,7 @@ export function help(config: IArgsConfig) { export function arg( type: T, config: IArgParam = {}, -): IArgConfig { +): IArgument { return { ...config, type, @@ -245,9 +303,14 @@ export function argparse(config: T) { return p! } + let onlyPositionals = false while (it.hasNext()) { const argument = it.next() - const isPositional = argument.substring(0, 1) !== '-' + if (argument === '--' && !onlyPositionals) { + onlyPositionals = true + continue + } + const isPositional = argument.substring(0, 1) !== '-' || onlyPositionals const argName = !isPositional ? processFlags(argument) : getNextPositional() @@ -260,6 +323,10 @@ export function argparse(config: T) { 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),