Add more tests for argparse

This commit is contained in:
Jerko Steiner 2019-08-05 22:07:37 +07:00
parent 86e90d28bb
commit cea9bc0dd1
2 changed files with 174 additions and 44 deletions

View File

@ -1,9 +1,9 @@
import {argparse} from './argparse' import {argparse, IArgsConfig} from './argparse'
describe('argparse', () => { describe('argparse', () => {
it('parses args', () => { it('parses args', () => {
const args = argparse(['--one', '1', '--two', '2', '--four'], { const args = argparse({
one: { one: {
type: 'string', type: 'string',
required: true, required: true,
@ -14,8 +14,8 @@ describe('argparse', () => {
}, },
four: { four: {
type: 'boolean', type: 'boolean',
} },
}) })(['--one', '1', '--two', '2', '--four'])
const one: string = args.one const one: string = args.one
const two: number = args.two const two: number = args.two
@ -26,12 +26,120 @@ describe('argparse', () => {
expect(four).toBe(true) 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', () => { it('throws when required args missing', () => {
expect(() => argparse([], { expect(() => argparse({
one: { one: {
type: 'string', type: 'string',
required: true, 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/)
})
}) })

View File

@ -11,6 +11,7 @@ export type TArgType<T extends TArgTypeName> =
export interface IArgConfig<T extends TArgTypeName> { export interface IArgConfig<T extends TArgTypeName> {
type: T type: T
alias?: string alias?: string
description?: string
default?: TArgType<T> default?: TArgType<T>
required?: boolean required?: boolean
} }
@ -19,7 +20,7 @@ export interface IArgsConfig {
[arg: string]: IArgConfig<TArgTypeName> [arg: string]: IArgConfig<TArgTypeName>
} }
export type TArgs<T> = { export type TArgs<T extends IArgsConfig> = {
[k in keyof T]: T[k] extends IArgConfig<infer A> ? [k in keyof T]: T[k] extends IArgConfig<infer A> ?
TArgType<A> : never TArgType<A> : never
} }
@ -35,7 +36,7 @@ const iterate = <T>(arr: T[]) => {
}, },
peek(): T { peek(): T {
return arr[i + 1] return arr[i + 1]
} },
} }
} }
@ -45,54 +46,75 @@ function assert(cond: boolean, message: string) {
} }
} }
export function argparse<T extends object>( function getDefaultValue(type: TArgTypeName) {
args: string[], switch (type) {
config: T extends IArgsConfig ? T : never, case 'number':
): TArgs<T> { return NaN
const result = {} as TArgs<T> case 'string':
return ''
case 'boolean':
return false
}
}
export const argparse = <T extends IArgsConfig>(
config: T,
) => (args: string[]): TArgs<T> => {
const result: any = {}
const it = iterate(args) const it = iterate(args)
const usedArgs: Record<string, true> = {}
const aliases: Record<string, string> = {} const aliases: Record<string, string> = {}
const requiredArgs = Object.keys(config).reduce((obj, arg) => { const requiredArgs = Object.keys(config).reduce((obj, arg) => {
const argConfig = config[arg] const argConfig = config[arg]
if (argConfig.default !== undefined) { result[arg] = argConfig.default !== undefined
result[arg] = argConfig.default ? argConfig.default
} : getDefaultValue(argConfig.type)
if (argConfig.alias) { if (argConfig.alias) {
assert(
argConfig.alias in aliases === false,
'Duplicate alias: ' + argConfig.alias)
aliases[argConfig.alias] = arg aliases[argConfig.alias] = arg
} }
obj[arg] = !!argConfig.required if (argConfig.required) {
obj[arg] = true
}
return obj return obj
}, {} as Record<string, boolean>) }, {} as Record<string, true>)
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()) { while (it.hasNext()) {
const arg = it.next() const arg = it.next()
assert(arg.substring(0, 1) === '-', 'Arguments must start with -') assert(arg.substring(0, 1) === '-', 'Arguments must start with -')
let argName: string const argName: string = processFlags(arg)
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 argConfig = config[argName] const argConfig = config[argName]
assert(!!argConfig, 'Unknown argument: ' + arg) assert(!!argConfig, 'Unknown argument: ' + arg)
delete requiredArgs[argName] delete requiredArgs[argName]
usedArgs[argName] = true
const peek = it.peek() const peek = it.peek()
switch (argConfig.type) { switch (argConfig.type) {
case 'string': case 'string':
@ -116,12 +138,12 @@ export function argparse<T extends object>(
} }
continue continue
default: default:
throw new Error('Unknown type:' + argConfig.type) assert(false, 'Unknown type: ' + argConfig.type)
} }
} }
assert(!Object.keys(requiredArgs).length, 'Missing required args: ' + assert(!Object.keys(requiredArgs).length, 'Missing required args: ' +
Object.keys(requiredArgs).map(r => '--' + r)) Object.keys(requiredArgs).join(', '))
return result return result
} }