Add better --help support in argparse

This commit is contained in:
Jerko Steiner 2019-08-13 20:28:20 +07:00
parent 7182684b4d
commit f1865a0cc4
7 changed files with 151 additions and 121 deletions

View File

@ -2,6 +2,15 @@ import {argparse, arg, IArgsConfig} from './argparse'
describe('argparse', () => { describe('argparse', () => {
const CMD = 'command'
const exit = jest.fn()
const log = jest.fn()
beforeEach(() => {
exit.mockClear()
log.mockClear()
})
it('parses args', () => { it('parses args', () => {
const args = argparse({ const args = argparse({
one: arg('string', {required: true}), one: arg('string', {required: true}),
@ -9,7 +18,7 @@ describe('argparse', () => {
four: { four: {
type: 'boolean', type: 'boolean',
}, },
}).parse(['--one', '1', '--two', '2', '--four']) }).parse([CMD, '--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,7 +35,7 @@ describe('argparse', () => {
bool: { bool: {
type: 'boolean', type: 'boolean',
}, },
}).parse([]) }).parse([CMD])
const value: boolean = result.bool const value: boolean = result.bool
expect(value).toBe(false) expect(value).toBe(false)
}) })
@ -36,7 +45,7 @@ describe('argparse', () => {
type: 'boolean', type: 'boolean',
required: true, required: true,
}, },
}).parse([])).toThrowError(/Missing required args: bool/) }).parse([CMD])).toThrowError(/Missing required args: bool/)
}) })
it('optionally accepts a true/false value', () => { it('optionally accepts a true/false value', () => {
const {parse} = argparse({ const {parse} = argparse({
@ -48,10 +57,10 @@ describe('argparse', () => {
type: 'string', type: 'string',
}, },
}) })
expect(parse(['--bool']).bool).toBe(true) expect(parse([CMD, '--bool']).bool).toBe(true)
expect(parse(['--bool', 'false']).bool).toBe(false) expect(parse([CMD, '--bool', 'false']).bool).toBe(false)
expect(parse(['--bool', 'true']).bool).toBe(true) expect(parse([CMD, '--bool', 'true']).bool).toBe(true)
expect(parse(['--bool', '--other', 'value'])).toEqual({ expect(parse([CMD, '--bool', '--other', 'value'])).toEqual({
bool: true, bool: true,
other: 'value', other: 'value',
}) })
@ -71,27 +80,27 @@ describe('argparse', () => {
alias: 'o', alias: 'o',
}, },
}) })
expect(parse([])).toEqual({ expect(parse([CMD])).toEqual({
a1: false, a1: false,
b: false, b: false,
other: '', other: '',
}) })
expect(parse(['-ab'])).toEqual({ expect(parse([CMD, '-ab'])).toEqual({
a1: true, a1: true,
b: true, b: true,
other: '', other: '',
}) })
expect(parse(['-ca'])).toEqual({ expect(parse([CMD, '-ca'])).toEqual({
a1: true, a1: true,
b: true, b: true,
other: '', other: '',
}) })
expect(parse(['-abo', 'test'])).toEqual({ expect(parse([CMD, '-abo', 'test'])).toEqual({
a1: true, a1: true,
b: true, b: true,
other: 'test', other: 'test',
}) })
expect(() => parse(['-abo'])).toThrowError(/must be a string: -abo/) expect(() => parse([CMD, '-abo'])).toThrowError(/must be a string: -abo/)
}) })
}) })
@ -102,18 +111,18 @@ describe('argparse', () => {
type: 'number', type: 'number',
}, },
}) })
expect(parse([])).toEqual({ expect(parse([CMD])).toEqual({
a: NaN, a: NaN,
}) })
expect(() => parse(['-a'])).toThrowError(/must be a number: -a/) expect(() => parse([CMD, '-a'])).toThrowError(/must be a number: -a/)
expect(() => parse(['-a', 'no-number'])) expect(() => parse([CMD, '-a', 'no-number']))
.toThrowError(/must be a number: -a/) .toThrowError(/must be a number: -a/)
expect(() => parse(['--a', 'no-number'])) expect(() => parse([CMD, '--a', 'no-number']))
.toThrowError(/must be a number: --a/) .toThrowError(/must be a number: --a/)
expect(parse(['-a', '10'])).toEqual({ expect(parse([CMD, '-a', '10'])).toEqual({
a: 10, a: 10,
}) })
expect(parse(['--a', '11'])).toEqual({ expect(parse([CMD, '--a', '11'])).toEqual({
a: 11, a: 11,
}) })
}) })
@ -129,13 +138,14 @@ describe('argparse', () => {
choices: [1, 2], choices: [1, 2],
}), }),
}) })
expect(() => parse(['--choice', 'c'])).toThrowError(/one of: a, b$/) expect(() => parse([CMD, '--choice', 'c'])).toThrowError(/one of: a, b$/)
expect(() => parse(['--num', '3'])).toThrowError(/must be one of: 1, 2$/) expect(() => parse([CMD, '--num', '3']))
expect(parse(['--choice', 'a', '--num', '1'])).toEqual({ .toThrowError(/must be one of: 1, 2$/)
expect(parse([CMD, '--choice', 'a', '--num', '1'])).toEqual({
choice: 'a', choice: 'a',
num: 1, num: 1,
}) })
expect(parse(['--choice', 'b', '--num', '2'])).toEqual({ expect(parse([CMD, '--choice', 'b', '--num', '2'])).toEqual({
choice: 'b', choice: 'b',
num: 2, num: 2,
}) })
@ -144,22 +154,25 @@ describe('argparse', () => {
describe('string[] and n', () => { describe('string[] and n', () => {
it('has a value of n = 1 by default', () => { it('has a value of n = 1 by default', () => {
const {parse, help} = argparse({ const {parse} = argparse({
value: { value: {
type: 'string[]', type: 'string[]',
}, },
}) help: arg('boolean'),
expect(parse([]).value).toEqual([]) }, exit, log)
expect(parse(['--value', 'one']).value).toEqual(['one']) expect(parse([CMD]).value).toEqual([])
expect(help()).toEqual([ expect(parse([CMD, '--value', 'one']).value).toEqual(['one'])
'[OPTIONS] ', parse([CMD, '--help'])
expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] `,
'', '',
'Options:', 'Options:',
' --value [VALUE] ', ' --value [VALUE] ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
it('can be used to extract finite number of values', () => { it('can be used to extract finite number of values', () => {
const {parse, help} = argparse({ const {parse} = argparse({
value: { value: {
type: 'string[]', type: 'string[]',
n: 3, n: 3,
@ -168,68 +181,84 @@ describe('argparse', () => {
type: 'number', type: 'number',
alias: 'o', alias: 'o',
}, },
}) help: arg('boolean'),
expect(parse([]).value).toEqual([]) }, exit, log)
expect(parse(['--value', 'a', 'b', '--other', '-o', '3'])).toEqual({ expect(parse([CMD]).value).toEqual([])
expect(parse([CMD, '--value', 'a', 'b', '--other', '-o', '3'])).toEqual({
value: ['a', 'b', '--other'], value: ['a', 'b', '--other'],
other: 3, other: 3,
help: false,
}) })
expect(help()).toEqual([ parse([CMD, '--help'])
'[OPTIONS] ', expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] `,
'', '',
'Options:', 'Options:',
' --value [VALUE1 VALUE2 VALUE3] ', ' --value [VALUE1 VALUE2 VALUE3] ',
'-o, --other number ', '-o, --other number ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
it('can be used to collect any remaining arguments when n = "+"', () => { it('can be used to collect any remaining arguments when n = "+"', () => {
const {parse, help} = argparse({ const {parse} = argparse({
value: arg('string[]', {n: '+', required: true}), value: arg('string[]', {n: '+', required: true}),
other: arg('number'), other: arg('number'),
}) help: arg('boolean'),
expect(() => parse([])).toThrowError(/Missing required args: value/) }, exit, log)
expect(parse(['--value', 'a', '--other', '3'])).toEqual({ expect(() => parse([CMD])).toThrowError(/Missing required args: value/)
expect(parse([CMD, '--value', 'a', '--other', '3'])).toEqual({
value: ['a', '--other', '3'], value: ['a', '--other', '3'],
other: NaN, other: NaN,
help: false,
}) })
expect(parse(['--other', '2', '--value', 'a', '--other', '3'])).toEqual({ expect(parse([CMD, '--other', '2', '--value', 'a', '--other', '3']))
.toEqual({
value: ['a', '--other', '3'], value: ['a', '--other', '3'],
other: 2, other: 2,
help: false,
}) })
expect(help()).toEqual([ parse([CMD, '--help'])
'[OPTIONS] ', expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] `,
'', '',
'Options:', 'Options:',
' --value VALUE... (required)', ' --value VALUE... (required)',
' --other number ', ' --other number ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
it('can collect remaining positional arguments when n = "*"', () => { it('can collect remaining positional arguments when n = "*"', () => {
const {parse, help} = argparse({ const {parse} = argparse({
value: arg('string[]', {n: '*', required: true, positional: true}), value: arg('string[]', {n: '*', required: true, positional: true}),
other: arg('number'), other: arg('number'),
}) help: arg('boolean'),
expect(parse(['a', 'b']).value).toEqual(['a', 'b']) }, exit, log)
expect(() => parse(['--other', '3']).value) expect(parse([CMD, 'a', 'b']).value).toEqual(['a', 'b'])
expect(() => parse([CMD, '--other', '3']).value)
.toThrowError(/Missing.*: value/) .toThrowError(/Missing.*: value/)
expect(parse(['--other', '2', '--', '--other', '3'])).toEqual({ expect(parse([CMD, '--other', '2', '--', '--other', '3'])).toEqual({
value: ['--other', '3'], value: ['--other', '3'],
other: 2, other: 2,
help: false,
}) })
expect(parse(['--', '--other', '3'])).toEqual({ expect(parse([CMD, '--', '--other', '3'])).toEqual({
value: ['--other', '3'], value: ['--other', '3'],
other: NaN, other: NaN,
help: false,
}) })
expect(parse(['--other', '3', 'a', 'b', 'c'])).toEqual({ expect(parse([CMD, '--other', '3', 'a', 'b', 'c'])).toEqual({
value: ['a', 'b', 'c'], value: ['a', 'b', 'c'],
other: 3, other: 3,
help: false,
}) })
expect(help()).toEqual([ parse([CMD, '--help'])
'[OPTIONS] [VALUE...]', expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] [VALUE...]`,
'', '',
'Options:', 'Options:',
' --value [VALUE...] (required)', ' --value [VALUE...] (required)',
' --other number ', ' --other number ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
}) })
@ -242,8 +271,8 @@ describe('argparse', () => {
positional: true, positional: true,
}, },
}) })
expect(parse([]).a).toBe(NaN) expect(parse([CMD]).a).toBe(NaN)
expect(parse(['12']).a).toBe(12) expect(parse([CMD, '12']).a).toBe(12)
}) })
it('works with booleans', () => { it('works with booleans', () => {
const {parse} = argparse({ const {parse} = argparse({
@ -252,10 +281,10 @@ describe('argparse', () => {
positional: true, positional: true,
}, },
}) })
expect(parse([]).a).toBe(false) expect(parse([CMD]).a).toBe(false)
expect(parse(['true']).a).toBe(true) expect(parse([CMD, 'true']).a).toBe(true)
expect(parse(['false']).a).toBe(false) expect(parse([CMD, 'false']).a).toBe(false)
expect(() => parse(['invalid'])).toThrowError(/true or false/) expect(() => parse([CMD, 'invalid'])).toThrowError(/true or false/)
}) })
it('works with strings', () => { it('works with strings', () => {
const {parse} = argparse({ const {parse} = argparse({
@ -264,8 +293,8 @@ describe('argparse', () => {
positional: true, positional: true,
}, },
}) })
expect(parse([]).a).toBe('') expect(parse([CMD]).a).toBe('')
expect(parse(['a']).a).toBe('a') expect(parse([CMD, 'a']).a).toBe('a')
}) })
it('works with multiple positionals', () => { it('works with multiple positionals', () => {
const {parse} = argparse({ const {parse} = argparse({
@ -278,7 +307,7 @@ describe('argparse', () => {
positional: true, positional: true,
}, },
}) })
expect(parse(['aaa', 'bbb'])).toEqual({ expect(parse([CMD, 'aaa', 'bbb'])).toEqual({
a: 'aaa', a: 'aaa',
b: 'bbb', b: 'bbb',
}) })
@ -296,7 +325,7 @@ describe('argparse', () => {
type: 'string', type: 'string',
}, },
}) })
expect(parse(['--arg1', 'one', '2', '--arg3', 'three'])).toEqual({ expect(parse([CMD, '--arg1', 'one', '2', '--arg3', 'three'])).toEqual({
arg1: 'one', arg1: 'one',
arg2: 2, arg2: 2,
arg3: 'three', arg3: 'three',
@ -305,23 +334,28 @@ describe('argparse', () => {
}) })
describe('help', () => { describe('help', () => {
it('returns help string', () => { it('prints help string and exits', () => {
const {help} = argparse({ const {parse} = argparse({
one: arg('string'), one: arg('string'),
two: arg('number'), two: arg('number'),
three: arg('boolean'), three: arg('boolean'),
}) help: arg('boolean'),
expect(help()).toEqual([ }, exit, log)
'[OPTIONS] ', expect(exit.mock.calls.length).toBe(0)
parse([CMD, '--help'])
expect(exit.mock.calls.length).toBe(1)
expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] `,
'', '',
'Options:', 'Options:',
' --one string ', ' --one string ',
' --two number ', ' --two number ',
' --three boolean ', ' --three boolean ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
it('returns help string with alias, description, and samples', () => { it('returns help string with alias, description, and samples', () => {
const {help} = argparse({ const {parse} = argparse({
one: arg('string', { one: arg('string', {
description: 'first argument', description: 'first argument',
required: true, required: true,
@ -336,15 +370,20 @@ describe('argparse', () => {
three: arg('number', { three: arg('number', {
positional: true, positional: true,
}), }),
}) help: arg('boolean'),
expect(help()).toEqual([ }, exit, log)
'[OPTIONS] TWO [THREE]', expect(exit.mock.calls.length).toBe(0)
parse([CMD, '--help'])
expect(exit.mock.calls.length).toBe(1)
expect(log.mock.calls[0][0]).toEqual([
`${CMD} [OPTIONS] TWO [THREE]`,
'', '',
'Options:', 'Options:',
'-o, --one string first argument ' + '-o, --one string first argument ' +
'(required, default: choice-1, choices: choice-1,choice-2)', '(required, default: choice-1, choices: choice-1,choice-2)',
' --two number (required)', ' --two number (required)',
' --three number ', ' --three number ',
' --help boolean ',
].join('\n')) ].join('\n'))
}) })
@ -356,7 +395,7 @@ describe('argparse', () => {
type: 'string', type: 'string',
required: true, required: true,
}, },
}).parse([])).toThrowError(/missing required/i) }).parse([CMD])).toThrowError(/missing required/i)
}) })
it('throws when arg type is unknown', () => { it('throws when arg type is unknown', () => {
@ -364,7 +403,7 @@ describe('argparse', () => {
a: { a: {
type: 'test', type: 'test',
} as any, } as any,
}).parse(['-a'])).toThrowError(/Unknown type: test/) }).parse([CMD, '-a'])).toThrowError(/Unknown type: test/)
}) })
}) })

View File

@ -16,8 +16,6 @@ export const N_DEFAULT_VALUE = 1
export type TNumberOfArgs = number | '+' | '*' export type TNumberOfArgs = number | '+' | '*'
export let exit = () => process.exit()
export interface IArgParam<T extends TArgTypeName> { export interface IArgParam<T extends TArgTypeName> {
alias?: string alias?: string
description?: string description?: string
@ -142,10 +140,6 @@ function extractArray(
return array return array
} }
export function isHelp(argv: string[]) {
return argv.some(a => /^(-h|--help)$/.test(a))
}
function checkChoice<T>(argument: string, choice: T, choices?: T[]) { function checkChoice<T>(argument: string, choice: T, choices?: T[]) {
if (choices) { if (choices) {
assert( assert(
@ -161,7 +155,7 @@ export function padRight(str: string, chars: number) {
return str return str
} }
export function help(config: IArgsConfig) { export function help(command: string, config: IArgsConfig) {
const keys = Object.keys(config) const keys = Object.keys(config)
function getArrayHelp( function getArrayHelp(
@ -189,6 +183,7 @@ export function help(config: IArgsConfig) {
} }
const positionalHelp = [ const positionalHelp = [
command,
'[OPTIONS]', '[OPTIONS]',
keys keys
.filter(k => config[k].positional) .filter(k => config[k].positional)
@ -237,12 +232,16 @@ export function arg<T extends TArgTypeName>(
} }
} }
export function argparse<T extends IArgsConfig>(config: T) { export function argparse<T extends IArgsConfig>(
config: T,
exit: () => void = () => process.exit(),
/* tslint:disable-next-line */
log: (message: string) => void = console.log.bind(console),
) {
return { return {
help(): string {
return help(config)
},
parse(args: string[]): TArgs<T> { parse(args: string[]): TArgs<T> {
const command = args[0]
args = args.slice(1)
const result: any = {} const result: any = {}
const it = iterate(args) const it = iterate(args)
@ -315,6 +314,12 @@ export function argparse<T extends IArgsConfig>(config: T) {
? processFlags(argument) ? processFlags(argument)
: getNextPositional() : getNextPositional()
const argConfig = config[argName] 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) assert(!!argConfig, 'Unknown argument: ' + argument)
delete requiredArgs[argName] delete requiredArgs[argName]
switch (argConfig.type) { switch (argConfig.type) {

View File

@ -1,22 +0,0 @@
export {arg} from '@rondo/argparse'
import {info} from './log'
import {
argparse as configure, IArgsConfig, isHelp, TArgs
} from '@rondo/argparse'
export let exit = () => process.exit()
export function argparse<T extends IArgsConfig>(config: T) {
const parser = configure(config)
return {
...parser,
parse(args: string[]): TArgs<T> {
const result = parser.parse(args)
if ('help' in config && isHelp(args)) {
info(parser.help())
exit()
}
return result
},
}
}

View File

@ -1,7 +1,7 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as log from '../log' import * as log from '../log'
import * as p from 'path' import * as p from 'path'
import {argparse, arg} from '../argparse' import {argparse, arg} from '@rondo/argparse'
import {findNodeModules} from '../modules' import {findNodeModules} from '../modules'
import {join} from 'path' import {join} from 'path'
import {run} from '../run' import {run} from '../run'
@ -9,7 +9,7 @@ import {run} from '../run'
const tsc = 'ttsc' const tsc = 'ttsc'
export async function build(...argv: string[]) { export async function build(...argv: string[]) {
const {parse, help} = argparse({ const {parse} = argparse({
project: arg('string', { project: arg('string', {
alias: 'p', alias: 'p',
default: '.', default: '.',

View File

@ -1,11 +1,11 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as log from '../log' import * as log from '../log'
import * as path from 'path' import * as path from 'path'
import {argparse, arg} from '../argparse' import {argparse, arg} from '@rondo/argparse'
import {run} from '../run' import {run} from '../run'
export async function newlib(...argv: string[]) { export async function newlib(...argv: string[]) {
const {parse, help} = argparse({ const {parse} = argparse({
name: arg('string', {positional: true, required: true}), name: arg('string', {positional: true, required: true}),
namespace: arg('string', {default: '@rondo'}), namespace: arg('string', {default: '@rondo'}),
help: arg('boolean', { help: arg('boolean', {

View File

@ -1,6 +1,6 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as ts from 'typescript' import * as ts from 'typescript'
import {argparse, arg} from '../argparse' import {argparse, arg} from '@rondo/argparse'
function isObjectType(type: ts.Type): type is ts.ObjectType { function isObjectType(type: ts.Type): type is ts.ObjectType {
return !!(type.flags & ts.TypeFlags.Object) return !!(type.flags & ts.TypeFlags.Object)
@ -210,11 +210,9 @@ export function typecheck(...argv: string[]) {
if (typeDefinitions.has(type)) { if (typeDefinitions.has(type)) {
return return
} }
// if (type.aliasSymbol) { if (type.aliasSymbol) {
// // TODO figure out how to prevent iterating of properties from types throw new Error('Type aliases are not supported')
// // such as strings }
// return
// }
const typeParameters: ts.TypeParameter[] = [] const typeParameters: ts.TypeParameter[] = []
const expandedTypeParameters: ts.Type[] = [] const expandedTypeParameters: ts.Type[] = []
const allRelevantTypes: ts.Type[] = [] const allRelevantTypes: ts.Type[] = []

View File

@ -2,22 +2,32 @@
import * as commands from './commands' import * as commands from './commands'
import * as log from './log' import * as log from './log'
import {TCommand} from './TCommand' import {TCommand} from './TCommand'
import {argparse, arg} from '@rondo/argparse'
async function run(...argv: string[]) { const {parse} = argparse({
const commandName = argv[0] help: arg('boolean'),
if (!(commandName in commands)) { debug: arg('boolean'),
command: arg('string', {required: true, positional: true}),
other: arg('string[]', {n: '*', positional: true}),
})
type TArgs = ReturnType<typeof parse>
async function run(args: TArgs) {
if (!(args.command in commands)) {
const c = Object.keys(commands).filter(cmd => !cmd.startsWith('_')) const c = Object.keys(commands).filter(cmd => !cmd.startsWith('_'))
log.info(`Available commands:\n\n${c.join('\n')}`) log.info(`Available commands:\n\n${c.join('\n')}`)
return return
} }
const command = (commands as any)[commandName] as TCommand const command = (commands as any)[args.command] as TCommand
await command(...argv.slice(1)) await command(args.command, ...args.other)
} }
if (typeof require !== 'undefined' && require.main === module) { if (typeof require !== 'undefined' && require.main === module) {
run(...process.argv.slice(2)) const args = parse(process.argv.slice(1))
run(args)
.catch(err => { .catch(err => {
log.error('> ' + err.message) log.error('> ' + (args.debug ? err.stack : err.message))
process.exit(1) process.exit(1)
}) })
} }