UNPKG

chedder

Version:
557 lines (498 loc) 19.9 kB
/** #Simple and minimum command line args parser * ##Functionalities: * * Rebuilds JSON object from several CL args. * --Note: spaces *must* be used around { and } * ##Separates positional arguments from --options ------------------------------- ``` >myContract transfer { account_id:luciotato, dest:other.account.betanet, stake:false } --amount 100N result: positional: [ { account_id:"luciotato", dest:"other.account.betanet", stake:false, } ] options: [ "amount" : "100_000_000_000_000_000_000_000_000" ] ``` ----------------------------- ## Planned functionalities: ### parse [ ] */ import { sep } from "path" // host OS path separator import { inspect } from "util" import * as color from './color.js' export type OptionDeclaration = { shortName: string valueType?: string helpText?: string value?: string|number|boolean } // ---------------------------------------------------- // construct and show help page based on valid options // ---------------------------------------------------- export function ShowHelpOptions(optionsDeclaration: Record<string,OptionDeclaration>) :void { // show help about declared options console.log() console.log("-".repeat(60)) console.log("Options:") for (const key in optionsDeclaration) { let line = "" const opt = optionsDeclaration[key] let text = "--" + key if (opt.valueType) text = text + " " + opt.valueType if (opt.shortName) { text = text + ", -" + opt.shortName if (opt.valueType) text = text + " " + opt.valueType } line = ` ${text}`.padEnd(50) + (opt.helpText ? opt.helpText : "") console.log(line) } console.log("-".repeat(60)) } // -------------------------- // -- main exported class -- // -------------------------- export class CommandLineArgs { clArgs: string[] // initial list process.argv positional: (string | Record<string,unknown>)[] // string or JSON objects -- positional arguments optDeclarations: Record<string,OptionDeclaration>; // pointer to passed option declarations constructor(options: Record<string,OptionDeclaration>) { this.clArgs = process.argv this.optDeclarations = options this.positional = [] // remove 'node' if called as a node script if (this.clArgs.length && (this.clArgs[0] === 'node' || this.clArgs[0].endsWith(sep + 'node')) || this.clArgs[0].endsWith(sep + 'node.exe') ) { this.clArgs = this.clArgs.slice(1) } // remove this script/executable name from command line arguments this.clArgs = this.clArgs.slice(1) // process each item separating options from positional args // First: process --options for (const key in options) { const optionDecl = options[key] // search for option name & variations const pos = this.searchOption(optionDecl) if (pos >= 0) { // found in command line args const literal = this.clArgs[pos] // as written this.clArgs.splice(pos, 1) // remove from cl args if (optionDecl.valueType) { // has a value if (pos >= this.clArgs.length) { color.logErr("expecting value after " + literal) process.exit(1) } const value = this.clArgs[pos] // take value options[key].value = value // set value this.clArgs.splice(pos, 1) // also remove value from list } else // valueless option { options[key].value = true // set as present } } } // if at this point there are still --options in the command line args array, those are unknown options let hasErrors = false for (const item of this.clArgs) { if (item.startsWith("-")) { color.logErr("UNKNOWN option: " + item) hasErrors = true } } if (hasErrors) { ShowHelpOptions(options) process.exit(1) } // create consumable positional arguments, parsing also JSON command-line format for (let index = 0; index < this.clArgs.length; index++) { const item = this.clArgs[index] if (item == "{") { // a JSON object in the command line const extracted = this.extractJSONObject(index) this.positional.push(extracted.value) index = extracted.end } else { this.positional.push(item) } } } /** * When the first argument is the command to execute * returns "" if there's no arguments */ getCommand():string { if (this.positional.length > 0 && typeof this.positional[0] !== "string") { color.logErr("expected a command as first argument'") process.exit(1) } else { if (this.positional.length === 0) return "" // take the first argument as this.command return this.positional.shift() as string } } /** * consume one string from the positional args * if it matches the expected string * returns false if the next arg doesn't match * @param which which string is expected */ optionalString(which:string):boolean { if (this.positional.length == 0) return false if (typeof this.positional[0] !== "string") { color.logErr(`expected a string argument, got {... }`) process.exit(1) } if (this.positional[0] == which) { this.positional.shift() // consume return true } return false // not the expected string } /** * requires a string as the next positional argument * @param name */ consumeString(name: string):string { if (this.positional.length == 0) { color.logErr(`expected '${name}' argument`) process.exit(1) } if (typeof this.positional[0] !== "string") { color.logErr(`expected ${name} string argument, got {... }`) process.exit(1) } return this.positional.shift() as string } /** * requires an amount in NEAR or YOCTO as the next positional argument * @param name */ consumeAmount(name: string, units: "N"|"Y"|"I"|"F"): string { const value = this.consumeString(name) return this.convertAmount(value, units, name) } /** * requires a JSON as the next positional arg * @param name */ // eslint-disable-next-line @typescript-eslint/no-explicit-any consumeJSON(name: string):any { if (this.positional.length == 0) { color.logErr(`expected ${name} as { }`) process.exit(1) } if (typeof this.positional[0] === "string") { color.logErr(`expected ${name} as {... } got a string: '${this.positional[0]}'`) process.exit(1) } return this.positional.shift() as Record<string,unknown> } moreArgs():boolean { return this.positional.length > 0 } /** * marks the end of the required arguments * if there are more arguments => error */ noMoreArgs():void { if (this.positional.length) { color.logErr(`unrecognized extra arguments`) console.log(inspect(this.positional)) process.exit(1) } } private findDeclarationKey(opt: OptionDeclaration) { for (const key in this.optDeclarations) { if (opt.shortName && this.optDeclarations[key].shortName == opt.shortName) return key if (opt.helpText && this.optDeclarations[key].helpText == opt.helpText) return key } throw new Error("shortName|helpText not found in declarations: " + inspect(opt)) } /** * requires the presence of an option with a string value * @param optionName option name */ requireOptionString(opt: OptionDeclaration): void { if (opt.value == undefined || opt.value == "" ) { const key = this.findDeclarationKey(opt) color.logErr(`required --${key}`) process.exit(1) } } /** * requires the presence of an option with an amount * @param optionName option name */ requireOptionWithAmount(opt: OptionDeclaration, units: "N" | "Y"): void { const value: string = opt.value? opt.value.toString().trim() : "" const key = this.findDeclarationKey(opt) if (!value) { color.logErr(`required --${key} [number]`) process.exit(1) } const converted = this.convertAmount(value, units, key) opt.value = converted // store in the required units } /** * search for the presence of an option * removes it from the options if found * * @param optionName option name */ consumeOption(opt: OptionDeclaration): string { const value: string = opt.value as string if (value) { // found opt.value = undefined // remove from options (consume) } return value } /** * converts an argument from the command line into a numeric string expressed in the required units * example: * convertAmount("10N","N") => "10" * convertAmount("1.25N","Y") => "12500000000000000000000000" * convertAmount("1365465465464564654654Y","N") => "0.00000000001365465465464564654654" * convertAmount("100_000_000Y","Y") => "100000000" * * @param value string as read from the command line * @param requiredUnits N|Y unit in which the amount is required */ convertAmount(value: string, requiredUnits: "N"|"Y"|"I"|"F", name:string): string { let result = value.toUpperCase() name = color.yellow + name + color.normal result = result.replace("_", "") // allow 100_000_000, ignore _ if (result.endsWith("Y")) { // value ends in YOCTOS if (result.includes(".")) { color.logErr(name + ": invalid amount format, YOCTOS can't have decimals: " + value) process.exit(1) } result = result.slice(0, -1) // remove Y if (requiredUnits == "Y") { return result } // already in Yoctos if (requiredUnits == "I"||requiredUnits == "F") { return result } // NEARS required -- convert to NEARS if (result.length <= 24) { result = "0." + result.padStart(24, '0').slice(-24) } else { // insert decimal point at 1e24 result = result.slice(0, result.length - 24) + "." + result.slice(-24) } return result } else { // other, assume amount in NEARS (default) if (!result.slice(-1).match(/\d|N|I|F/)) { //should end with N|I|F or a digit color.logErr(name + ": invalid denominator, expected Y|N|I|F => yoctos|near|int|float. Received:" + result) process.exit(1) } if (result.endsWith("I")||result.endsWith("F")) { result = result.slice(0, -1) // remove denom, store as number return result } if (result.endsWith("N")) result = result.slice(0, -1) // remove N if (requiredUnits == "N") { return result } // already in Nears // Yoctos required -- convert to yoctos const parts = result.split(".") if (parts.length > 2) { color.logErr(name + ": invalid amount format, too many decimal points: " + value) process.exit(1) } if (parts.length == 1) { parts.push("") } // .0 const decimalString = parts[1].padEnd(24, '0') result = parts[0] + "" + decimalString // +""+ is for making sure + means concat here return result } } /** * extract { a: b, d:100 } from the command line as a JSON object * @param start open brace position in this.list */ private extractJSONObject(start: number) { // find the closing "}" let opened = 1 let end = -1 for (let n = start + 1; n < this.clArgs.length; n++) { const item = this.clArgs[n] if (item == "{") { opened++ } else if (item == "}") { opened-- if (opened == 0) { end = n break } } } if (end == -1) { // unmatched opener error color.logErr("Unmatched '{' . remember to put spaces around { and }") this.clArgs[start] = color.yellow + "{" + color.normal console.log(this.clArgs.join(" ")) process.exit(1) } // Here we have start & end for matching { } const resultObj:Record<string,unknown> = {} for (let index = start + 1; index < end; index++) { let propName = this.clArgs[index] let propValue if (propName == ",") continue if ("{}".includes(propName)) { color.logErr("expected name:value") this.clArgs[index] = color.yellow + propName + color.normal console.log(this.clArgs.slice(start, end + 1).join(" ")) process.exit(1) } const parts = propName.split(":") if (parts.length > 2) { color.logErr(` too many ':' (found ${parts.length - 1}) at ${propName}`) process.exit(1) } propName = parts[0].trim() propValue = parts[1].trim() if (propValue == undefined || propValue == "") { // let's assume the user typed "name: value" instead of "name:value" index++ // take the next arg propValue = this.clArgs[index] if (propValue.endsWith(":")) { color.logErr(` missing value after ':' for ${propName}`) } if (index >= end || propValue == "}") { console.log(`ERROR: expected value after ${propName}`) process.exit(1) } } if (propValue == "{") { // subordinated object const subObj = this.extractJSONObject(index) // recursive*** // store as object resultObj[propName] = subObj.value index = subObj.end // skip internal object continue } // it's a string // remove ending "," if it's there if (propValue.endsWith(",")) propValue = propValue.slice(0, propValue.length - 1) // check if it's a number if (propValue.toUpperCase().match(/^[0-9.]+[Y|N|I|F]{0,1}$/)) { // amount (optionally [Y|N|I|F] expressed in nears. yoctos, integer or float propValue = this.convertAmount(propValue, "Y", propName) // process and convert to Yoctos if expressed in nears } // store resultObj[propName] = propValue } // end for // return positions and composed object return { start: start, end: end, value: resultObj } } // --------------------------- /** * removes valueless options into the options object * returns true if the option was present * @param shortName short name, e.g -verb * @param fullName full name,e.g. --verbose */ /* option(shortName: string, fullName: string) { //if .getPos(shortOption,argName) into var pos >= 0 var pos = this.removeOption(shortName, fullName); if (pos >= 0) { this.positional.splice(pos, 1); return true; }; return false; } */ // --------------------------- /** * removes options that has a value after it * @param shortName short name, e.g -ata 100N * @param fullName full name,e.g. --attach 100N */ /* valueFor(shortName: string, fullName: string) { var pos = this.removeOption(shortName, fullName); if (pos >= 0) { //found var value = this.positional[pos + 1]; //take value this.positional.splice(pos, 2); return value; }; return undefined; //not found } */ // --------------------------- /** * search for an option in the command line args, with variations * removes the option from the array * return position in the array where it was found|-1 */ private searchOption(option: OptionDeclaration): number { const name = this.findDeclarationKey(option) const shortName = option.shortName // search several possible forms of the option, e.g. -o --o -outDir --outDir const variants = ['-' + name, '--' + name] if (shortName) { variants.push('--' + shortName, '-' + shortName) } // for each item in list for (const variant of variants) { const inx = this.clArgs.indexOf(variant) if (inx >= 0) { return inx // found } } return -1// not found } // ---------------------------------------------------- // construct and show help page based on valid options // ---------------------------------------------------- ShowHelpOptions() { // show help about declared options console.log() console.log("-".repeat(60)) console.log("Options:") for (const key in this.optDeclarations) { let line = "" const opt = this.optDeclarations[key] let text = "--" + key if (opt.valueType) text = text + " " + opt.valueType if (opt.shortName) { text = text + ", -" + opt.shortName if (opt.valueType) text = text + " " + opt.valueType } line = ` ${text}`.padEnd(50) + (opt.helpText ? opt.helpText : "") console.log(line) } console.log("-".repeat(60)) } static getMethods(API:Record<string,any>) { const list=[] const proto = Object.getPrototypeOf(API) for (const key of Object.getOwnPropertyNames(proto)) { if (key != "constructor" && key != "_call" && key != "_view" && !key.endsWith("_HELP")) { list.push(key) } } return list } // ---------------------------------------------------- // construct and show a help page based on the API for the commands // ---------------------------------------------------- ShowHelpPage(forCommand:string, API:Record<string,any>) { // list functions in the Extended and ContractAPI class, except the class constructor and view/call/HELP helpers const list = CommandLineArgs.getMethods(API) .concat(CommandLineArgs.getMethods(Object.getPrototypeOf(API))) list.sort() // print all commands and their help if it's there for (const name of list) { if (forCommand && name!=forCommand) continue; console.log("-".repeat(60)) console.log('command: ' + color.yellow + name + color.normal) // name the command //@ts-ignore if (API[name + "_HELP"]) { //if there's help... //@ts-ignore console.log(API[name + "_HELP"]()); // print the help } } this.ShowHelpOptions() } } // end class CommandLineArgs