Skip to content

Instantly share code, notes, and snippets.

@J-Cake
Last active December 1, 2021 12:30
Show Gist options
  • Save J-Cake/ae50a5b54270c1a01fefb16e31cc490c to your computer and use it in GitHub Desktop.
Save J-Cake/ae50a5b54270c1a01fefb16e31cc490c to your computer and use it in GitHub Desktop.
A simple but capable arg parser for CLI interfaces.
type Option<Parser extends (arg: string) => any> =
({ long?: string, short: string } | { long: string, short?: string })
& { format?: Parser, default?: ReturnType<Parser>, description?: string };
export type Parameters<Names extends { [name: string]: any }> = { [name in keyof Names]: Option<Names[name]> };
export type Options<Main, Names extends { [name: string]: any }, Config extends Parameters<Names>> = { default: Main }
& { [Parameter in keyof Config]: Config[Parameter] extends Option<(arg: string) => infer Type> ? Type : boolean };
export default function parse<Main, Names extends { [name: string]: any }>(parameters: Parameters<Names>, main?: (arg: string) => Main): (args: string[]) => Options<Main, Names, typeof parameters> {
return function (args: string[]): Options<Main, Names, typeof parameters> {
const options: Options<Main, Names, typeof parameters> = {} as Options<Main, Names, typeof parameters>;
if (main)
options["default"] = main(args[0]);
for (const i in parameters) {
const param = parameters[i];
if ('default' in param && 'format' in param)
options[i] = param.default;
if (param.short?.length > 1)
throw `short names should only contain 1 character`;
for (let j = 0, arg = args[j]; j < args.length; arg = args[++j])
if ((param.long && arg.startsWith('--') && arg.slice(2) === parameters[i].long) ||
(param.short && arg.startsWith('-') && !arg.startsWith('--') && arg.slice(1).includes(param.short)))
options[i] = param.format ? param.format(args[++j]) : !param.default;
if (!(i in options) && param.format)
throw `required parameter '${i}' (${param.long ? '--' + (param.short ? param.long + ' or ' : param.long) : ''}${param.short ? '-' + param.short : ''})`;
}
return options;
}
}
import url from 'url';
import fs from 'fs';
export function Url(arg: string): url.URL {
return new url.URL(arg);
}
export function Path(exists: boolean): (arg: string) => string {
return function (arg: string): string {
const tidy = arg.split('/').filter(i => i).join('/');
const out = arg.startsWith('/') ? '/' + tidy : tidy;
if (exists && fs.existsSync(out) || !exists)
return out;
else throw `File does not exist`;
}
}
export function Int(arg: string): number {
const num = parseInt(arg);
if (isNaN(num))
throw `Invalid integer ${arg}`;
return num;
}
export function Float(arg: string): number {
const num = parseFloat(arg);
if (isNaN(num))
throw `Invalid float ${arg}`;
return num;
}
export function DateTime(arg: string): Date {
return new Date(arg);
}
export function oneOf<List extends readonly string[]>(options: List, caseSensitive: boolean = true): (arg: string) => List[number] {
return function (arg: string): string {
if (caseSensitive && options.includes(arg))
return arg;
else if (!caseSensitive)
for (const i of options)
if (i.toLowerCase() === arg.toLowerCase())
return i;
throw `Expected one of ${options.slice(0, 3).join(', ')}${options.length > 3 ? '...' : ''}`;
}
}

Args

It doesn't look like much, and it isn't but it's quite handy. The basic idea is that you provide a config object, and get back a function which you pass the args in, and you'll get an object back which resembles the config object, except populated with the values fetched from the command line. And the greatest thing - it's fully typed! (yea don't try to make sense of the typescript bit, you'll be busy for hours)

Code

The process is divided into two parts: Configuring and Parsing. Watch this:

// node ./weirdcompiler.js ./file.txt -i ./libs/lib1,./libs/lib2,./libs/lib3 -o ./out.txt --lang Java
const arguments = parse({
  include: {
    short: 'i',
    long: 'include',
    format: format.csv(format.path(true)),
    default: []
  },
  out: {
    short: 'o',
    format: format.path(false),
    default: null
  },
  language: {
    short: 'l',
    long: 'lang',
    format: format.oneof(['c', 'c++', 'js', 'ts', 'java'], false)
  }
}, format.path(true))(process.argv.slice(2)) // slice the first two off, so we don't get the node executable and the current file

// arguments: {default: './file.txt', include: ['./libs/lib1', './libs/lib2', './libs/lib3], out: './out.txt', language: 'java'}

How TF?

Simple. The keys of the config object are used as the names of the options. the values are configurations for each option. You must provide at least one of short or long. These represent what flags will be assigned to each option. the short option will match any -{letter} option, and long matches --{name}. It's worth noting that short can only contain one character.

Next, each option can define a format option. This determines the matcher to be used when parsing the option. For instance, if format is set to format.Int, the following would return a number: --some-option 10, whereas providing a non-integer would yield a type error: --some-option eggs

format is optional. If omitted, the type is inferred to boolean, this means that no value is expected. If no format is provided, short options can be aggregated. -omg, would set each option whose short properties are either o, m, or g to the inverse of their default option (which is false if omitted).

For instance, the following config:

parse({
  open: {
    short: 'o',
    default: true
  }
})(['-o'])

would set its open value to false, such that allowing --no-... options is possible.

If the default option is omitted, the parameter is considered required. Failing to provide it through the args will cause an error. The type of default must be assignable to ReturnType<format>, meaning the type of default, but match any type that came out of the format parser. Typescript will complain if not.

Format

CLI options can come in many shapes and sizes, that's why converting them to a more useful format is a pretty important job of a CLI parser. The system employed here is designed to be scalable. The only thing required to define your own is explicit typing. The format.ts file contains a bunch of examples which you can use to create your own.

It's a good idea to throw errors when the string cannot be parsed to the correct type, as this will inform users that they are idiots and didn't put in the right value.

Format functions go into the format property of options, and take a single string as an input, and can return any non-void value. Defining a custom parser can be done like so:

const args = parse({
  datetime: {
    ...
    format(arg: string): Date { return new Date(arg) }
  }
})([..., '12-10-21'])

The default parameter.

You may have noticed how some command lines will take the first parameter and infer its property name automatically. There is similar functionality here.

In the parse function, the second parameter specifies a parser for the default option, which if omitted makes the default option redundant.

It's value is placed into the default key of the returned option map.

const args = parse({}, formats.Int)['0'] // {default: 0}

All in 70 lines of TS. Enjoy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment