diff --git a/packages/argparse/src/argparse.test.ts b/packages/argparse/src/argparse.test.ts index d71ae9b..06df487 100644 --- a/packages/argparse/src/argparse.test.ts +++ b/packages/argparse/src/argparse.test.ts @@ -1,9 +1,9 @@ -import {argparse} from './argparse' +import {argparse, IArgsConfig} from './argparse' describe('argparse', () => { it('parses args', () => { - const args = argparse(['--one', '1', '--two', '2', '--four'], { + const args = argparse({ one: { type: 'string', required: true, @@ -14,8 +14,8 @@ describe('argparse', () => { }, four: { type: 'boolean', - } - }) + }, + })(['--one', '1', '--two', '2', '--four']) const one: string = args.one const two: number = args.two @@ -26,12 +26,120 @@ describe('argparse', () => { expect(four).toBe(true) }) + describe('boolean', () => { + it('is not required, and is false by default', () => { + const result = argparse({ + bool: { + type: 'boolean', + }, + })([]) + const value: boolean = result.bool + expect(value).toBe(false) + }) + it('can be made required', () => { + expect(() => argparse({ + bool: { + type: 'boolean', + required: true, + }, + })([])).toThrowError(/Missing required args: bool/) + }) + it('optionally accepts a true/false value', () => { + const parse = argparse({ + bool: { + type: 'boolean', + alias: 'b', + }, + other: { + type: 'string', + }, + }) + expect(parse(['--bool']).bool).toBe(true) + expect(parse(['--bool', 'false']).bool).toBe(false) + expect(parse(['--bool', 'true']).bool).toBe(true) + expect(parse(['--bool', '--other', 'value'])).toEqual({ + bool: true, + other: 'value', + }) + }) + it('can be grouped by shorthand (single dash) notation', () => { + const parse = argparse({ + a1: { + type: 'boolean', + alias: 'a', + }, + b: { + type: 'boolean', + alias: 'c', + }, + other: { + type: 'string', + alias: 'o', + }, + }) + expect(parse([])).toEqual({ + a1: false, + b: false, + other: '', + }) + expect(parse(['-ab'])).toEqual({ + a1: true, + b: true, + other: '', + }) + expect(parse(['-ca'])).toEqual({ + a1: true, + b: true, + other: '', + }) + expect(parse(['-abo', 'test'])).toEqual({ + a1: true, + b: true, + other: 'test', + }) + expect(() => parse(['-abo'])).toThrowError(/must be a string: -abo/) + }) + }) + + describe('number', () => { + it('sets to NaN by default', () => { + const parse = argparse({ + a: { + type: 'number', + }, + }) + expect(parse([])).toEqual({ + a: NaN, + }) + expect(() => parse(['-a'])).toThrowError(/must be a number: -a/) + expect(() => parse(['-a', 'no-number'])) + .toThrowError(/must be a number: -a/) + expect(() => parse(['--a', 'no-number'])) + .toThrowError(/must be a number: --a/) + expect(parse(['-a', '10'])).toEqual({ + a: 10, + }) + expect(parse(['--a', '11'])).toEqual({ + a: 11, + }) + }) + }) + it('throws when required args missing', () => { - expect(() => argparse([], { + expect(() => argparse({ one: { type: 'string', required: true, }, - })).toThrowError(/missing required/i) + })([])).toThrowError(/missing required/i) }) + + it('throws when arg type is unknown', () => { + expect(() => argparse({ + a: { + type: 'test', + } as any, + })(['-a'])).toThrowError(/Unknown type: test/) + }) + }) diff --git a/packages/argparse/src/argparse.ts b/packages/argparse/src/argparse.ts index 6aa35c0..2011134 100644 --- a/packages/argparse/src/argparse.ts +++ b/packages/argparse/src/argparse.ts @@ -11,6 +11,7 @@ export type TArgType = export interface IArgConfig { type: T alias?: string + description?: string default?: TArgType required?: boolean } @@ -19,7 +20,7 @@ export interface IArgsConfig { [arg: string]: IArgConfig } -export type TArgs = { +export type TArgs = { [k in keyof T]: T[k] extends IArgConfig ? TArgType : never } @@ -35,7 +36,7 @@ const iterate = (arr: T[]) => { }, peek(): T { return arr[i + 1] - } + }, } } @@ -45,56 +46,77 @@ function assert(cond: boolean, message: string) { } } -export function argparse( - args: string[], - config: T extends IArgsConfig ? T : never, -): TArgs { - const result = {} as TArgs +function getDefaultValue(type: TArgTypeName) { + switch (type) { + case 'number': + return NaN + case 'string': + return '' + case 'boolean': + return false + } +} + +export const argparse = ( + config: T, +) => (args: string[]): TArgs => { + const result: any = {} const it = iterate(args) - const usedArgs: Record = {} const aliases: Record = {} const requiredArgs = Object.keys(config).reduce((obj, arg) => { const argConfig = config[arg] - if (argConfig.default !== undefined) { - result[arg] = argConfig.default - } + result[arg] = argConfig.default !== undefined + ? argConfig.default + : getDefaultValue(argConfig.type) if (argConfig.alias) { + assert( + argConfig.alias in aliases === false, + 'Duplicate alias: ' + argConfig.alias) aliases[argConfig.alias] = arg } - obj[arg] = !!argConfig.required + if (argConfig.required) { + obj[arg] = true + } return obj - }, {} as Record) + }, {} as Record) - while(it.hasNext()) { + function getArgumentName(nameOrAlias: string): string { + return nameOrAlias in config ? nameOrAlias : aliases[nameOrAlias] + } + + function processFlags(arg: string): string { + if (arg.substring(1, 2) === '-') { + return arg.substring(2) + } + + const flags = arg.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 + } + + while (it.hasNext()) { const arg = it.next() assert(arg.substring(0, 1) === '-', 'Arguments must start with -') - let argName: string - if (arg.substring(1, 2) !== '-') { - // flags - const flags = arg.substring(1).split('') - - flags.slice(0, flags.length - 2) - .forEach(flag => { - const alias = aliases[flag] - const argConfig = config[alias] - assert(!!argConfig, 'Unknown flag: ' + flag) - assert(argConfig.type === 'boolean', 'The argument is not a flag/boolean: ' + flag) - delete requiredArgs[alias] - result[alias] = true - }) - - const lastArg = flags[flags.length - 1] - argName = aliases[lastArg] - } else { - argName = arg.substring(2) - } + const argName: string = processFlags(arg) const argConfig = config[argName] assert(!!argConfig, 'Unknown argument: ' + arg) delete requiredArgs[argName] - usedArgs[argName] = true const peek = it.peek() - switch(argConfig.type) { + switch (argConfig.type) { case 'string': assert(it.hasNext(), 'Value of argument must be a string: ' + arg) result[argName] = it.next() @@ -116,12 +138,12 @@ export function argparse( } continue default: - throw new Error('Unknown type:' + argConfig.type) + assert(false, 'Unknown type: ' + argConfig.type) } } assert(!Object.keys(requiredArgs).length, 'Missing required args: ' + - Object.keys(requiredArgs).map(r => '--' + r)) + Object.keys(requiredArgs).join(', ')) return result }