Skip to content

Instantly share code, notes, and snippets.

@sbrl
Last active March 9, 2020 18:24
Show Gist options
  • Save sbrl/3e8697c7f34c9f339f570efa92d7609f to your computer and use it in GitHub Desktop.
Save sbrl/3e8697c7f34c9f339f570efa92d7609f to your computer and use it in GitHub Desktop.
[CliParser.mjs] Minimal CLI parser for Node.js - uses Ansi.mjs #library
"use strict";
import fs from 'fs';
import a from './Ansi.mjs';
/**
* Represents a program and all it's arguments and subcommands.
* TODO: Publish on npm under the name "applause-cli", since it's inspired by clap?
* @license MPL-2.0
*/
class Program {
/**
* Whether this Program has subcommands or not.
* @return {Boolean}
*/
get has_subcommands() {
return Object.keys(this.subcommands).length > 0;
}
/**
* Whether this Program has any *global* arguments or not.
* @return {Boolean}
*/
get has_arguments() {
return Object.keys(this.arguments_global).length > 0
}
/**
* The max length - in characters - of all arguments (both global and subcommand-local).
* Useful for lining everything up neatly :P
* @return {number}
*/
get max_arg_length() {
return Object.values(this.arguments_global)
.concat(...Object.values(this.subcommands)
.map((subc) => Object.values(subc.arguments)))
.reduce((prev, next) => Math.max(prev, next.toString().length), 0);
}
/**
* Creates a new Program instance for specifying arguments and subcommands.
*/
constructor(npm_package_json_loc) {
this.npm_package = JSON.parse(fs.readFileSync(npm_package_json_loc, "utf-8"));
this.name = this.npm_package.name;
this.version = this.npm_package.version;
this.author = this.npm_package.author;
this.description = this.npm_package.description;
// Global argument definitions
this.arguments_global = {};
// Subcommand definitions
this.subcommands = {};
// The selected subcommand
this.current_subcommand = null;
// Extra args we found while parsing
this.extras = [];
this.options = {};
// Ansi colours
this.c_heading = a.fgreen + a.hicol
this.c_smallheading = a.fblue + a.hicol;
this.c_subcommand = a.fmagenta;
this.c_argument = a.fyellow;
}
/**
* Specifies the name of the program - defaults to the name from your package.json
* @param {string} str The name of the program
* @return {this}
*/
name(str) {
this.name = str;
return this;
}
/**
* Specifies the description of the program - defaults to the description from your package.json
* @param {string} str The description of the program
* @return {this}
*/
description(str) {
this.description = str;
return this;
}
/**
* Adds an argument to the program.
* @param {string} name The name of the argument
* @param {string} description A description of the argument
* @param {any} default_value The default value of the argument
* @param {Boolean} [type="string"] The type of this argument. Set to "boolean" for flags that have no value.
* @return {this}
*/
argument(name, description, default_value, type = "string") {
this.arguments_global[name] = new Argument(
name,
description,
default_value,
type
);
return this;
}
/**
* Adds a subcommand to the program.
* @param {string} name The name of the subcommand
* @param {string} description A description of the subcommand
* @return {Subcommand} The subcommand instance. Useful for specifying subcommand-specific arguments.
*/
subcommand(name, description) {
let subcommand = new Subcommand(name, description);
this.subcommands[name] = subcommand;
return subcommand;
}
/**
* Parses the given argument set.
* Note that the returned object will also include all the default values of all relevant arguments.
* @param {string[]} args The array of arguments to parse.
* @return {Object} The parsed arguments.
*/
parse(args) {
// Apply the default argument values
for(let name in this.arguments)
this.options[name] = this.arguments[name].default_value;
// Parse the specified options
for(let i = 0; i < args.length; i++) {
if(!args[i].startsWith("-")) {
// If a subcommand hasn't been specified yet, do so now
if(this.has_subcommands && this.current_subcommand == null) {
this.current_subcommand = args[i];
// Apply the default subcommand argument values
for(let name in this.arguments)
this.options[name] = this.arguments[name].default_value;
continue;
}
// Otherwise, note it down and move on
this.extras.push(args[i]);
continue;
}
// It's an argument
let argument_text = args[i].replace(/^-+/, "");
// Handle special cases
switch(argument_text) {
case "help":
this.write_help_exit();
break;
case "version":
this.write_version_exit();
break;
}
// Check the global arguments
let argument_obj = this.arguments_global[argument_text];
if(typeof argument_obj == "undefined" && this.current_subcommand !== null) {
argument_obj = this.subcommands[this.current_subcommand].arguments[argument_text];
}
if(typeof argument_obj == "undefined") {
// We couldn't find it
console.error(`${a.hicol}${a.fred}Error: Unknown argument ${args[i]}. Did you specify it after the subcommand?
Try --help for usage information.${a.reset}`);
process.exit(1);
}
// Found it!
// Apply the specified argument value
if(argument_obj.has_value) {
let parsed_value = argument_obj.parse_value(args[++i]);
if(argument_obj.multiple_values) {
if(!(this.options[argument_obj.name] instanceof Array))
this.options[argument_obj.name] = [];
this.options[argument_obj.name].push(parsed_value);
}
else
this.options[argument_obj.name] = parsed_value;
}
else
this.options[argument_obj.name] = true;
}
return this.options;
}
write_version_exit() {
console.log(this.version);
process.exit(0);
}
write_help_exit() {
let result = `${a.hicol}${this.name}${a.reset} - ${this.description}
${a.locol}By ${this.author}${a.reset}
${this.c_heading}Usage:${a.reset}
${" ".repeat(4)}${this.name}${this.has_subcommands ? " [subcommand]" : ""} [options]
`;
if(this.has_subcommands)
result += this._stringify_subcommands();
if(this.has_arguments)
result += this._stringify_global_options();
if(this.has_subcommands)
result += this._stringify_subcommand_args();
console.error(result);
process.exit(0);
}
_stringify_global_options() {
let result = `\n${this.c_heading}Global Options:${a.reset}\n`;
result += this._stringify_arg_list(Object.values(this.arguments_global));
return result;
}
_stringify_subcommand_args() {
let result = `\n${this.c_heading}Subcommand Options:${a.reset}\n`;
for(let subcommand of Object.values(this.subcommands)) {
if(!subcommand.has_arguments) continue;
result += ` ${this.c_smallheading}${subcommand.name}:${a.reset}\n`;
result += this._stringify_arg_list(
Object.values(subcommand.arguments),
" ".repeat(8)
);
}
return result;
}
_stringify_subcommands() {
let result = `\n${this.c_heading}Subcommands:${a.reset}\n`;
let max_subc_length = Object.values(this.subcommands)
.reduce((prev, next) => Math.max(prev, next.name.length), 0);
for(let subcommand of Object.values(this.subcommands)) {
result += ` ${this.c_subcommand}${subcommand.name.padStart(max_subc_length)}${a.reset} ${subcommand.description}\n`;
}
return result;
}
_stringify_arg_list(args, indent = " ") {
let result = "";
for(let arg of args) {
result += `${indent}${this.c_argument}${arg.toString().padStart(this.max_arg_length)}${a.reset} ${arg.description}\n`;
}
return result;
}
}
class Argument {
get multiple_values() {
return typeof this.type == "string" && this.type.endsWith("_multi");
}
constructor(name, description, default_value, type) {
this.name = name;
this.description = description;
this.default_value = default_value;
this.has_value = type !== "boolean";
if(typeof type == "function")
this.value_parser(type);
else
this.type = type;
}
/**
* Sets the type of this argument.
* @param {string} type The type of this argument.
* @return {this}
*/
type(type) {
this.type = type;
return this;
}
/**
* Sets a custom function for parsing values.
* @param {[type]} func [description]
* @return {[type]} [description]
*/
value_parser(func) {
this.type = "custom";
this.type_parser = func;
return this;
}
/**
* Parses a value according to the type of this argument.
* @param {any} val The value to parse.
* @return {any} The parsed value
*/
parse_value(val) {
if(typeof val === this.type)
return val;
if(typeof val == "string") {
switch(this.type.split("_")[0]) {
case "integer":
return parseInt(val);
case "float":
return parseFloat(val);
case "boolean":
return JSON.parse(val.toLowerCase());
case "custom":
return this.type_parser(val);
case "string":
return val;
default:
throw new Error(`Error: Unknown argument value type ${val} for argument --${this.name}`);
}
}
}
toString() {
return `--${this.name} value`;
}
}
class Subcommand {
get has_arguments() {
return Object.keys(this.arguments).length > 0
}
constructor(name, description) {
this.name = name;
this.description = description;
this.arguments = {};
}
argument(name, description, default_value, type = "string") {
this.arguments[name] = new Argument(name, description, default_value, type);
return this;
}
}
export default Program;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment