There are plenty of command line parsers available through npm (yargs, minimist, etc) and they all stem from a very strong POSIX root. But, we wanted something just slightly different. We wanted a parser that was specific for a slack command line structure that we were toying around with. The structure looked like this:
@botname {product-name} {action} <required-param> [optional-param]
With:
- {product-name} being a requirement in the command syntax
- {action} being a requirement in the command syntax
- Parameter order would be respected, if a parameter name was not specified then it’s position should indicate which parameter it is
- Parameter names are optional, but can be indicated by either a prefix of a single (-) or double (--) dash
- The values for named parameters would be indicated by spaces instead of equal signs
- And there’s a few other criteria in there that our brains just naturally figure out but we have to write into the code
Anyways, the point is that the design is just slightly different enough from a standard POSIX command line that we were going to need to build our own.
To do this we tried to figure out a simplified syntax to use in code. This syntax would be used in our hubot commands to parse the given input. Again, there are many parsers already built for this (regex comes to mind), but we built our own. The syntax looks like this:
product-name action {required-param-name} {optional-param:default value}
An example of this syntax would be:
servicepro close {ticketnumber} {memo:Verified and Closing}
If the given input was:
@botname servicepro close 5646831 “Memory usage has been lowered.”
The command line parser would need to return a javascript object that looked like this:
{
“ticketnumber”: “5646831”,
“memo”: “Memory usage has been lowered”,
“success”: true,
“errors”: {}
}
So, here’s the code with some poorly written tests commented out at the bottom:
function parse(format, input) { | |
var optionsIndex = format.indexOf("{") | |
var start = format.substring(0, optionsIndex).trim() | |
// console.log(`start = ${start}`) | |
var optionslist = format.substring(optionsIndex) | |
// console.log(`optionslist: ${optionslist}`) | |
var optionsSplit = optionslist.split(' ') | |
// console.log(`optionsSplit: ${optionsSplit}`) | |
// attempt to recombine options which might have multiterm default values | |
var recombinedOptions = [] | |
for(var i = 0; i < optionsSplit.length; i++) { | |
var term = optionsSplit[i] | |
if(term.startsWith('{') == false) { | |
recombinedOptions.push(term) | |
continue | |
} | |
if(term.endsWith('}') == false) { | |
var combined = term | |
while(term.endsWith('}') == false && i < optionsSplit.length) { | |
i++ | |
term = optionsSplit[i] | |
combined += " " + term | |
} | |
recombinedOptions.push(combined) | |
} else { | |
recombinedOptions.push(term) | |
} | |
} | |
optionsSplit = recombinedOptions | |
// parse out the default values | |
defaults = {} | |
optionsSplit.forEach(option => { | |
option = option.replace('{','') | |
option = option.replace('}','') | |
optionSplit = option.split(':') | |
name = optionSplit[0] | |
if(optionSplit.length === 1) { | |
value = "required" | |
} else { | |
value = optionSplit[1] | |
} | |
// console.log(`option: ${name} = ${value}`) | |
defaults[name] = value | |
}); | |
argv = JSON.parse(JSON.stringify(defaults)); // deep copy | |
used = JSON.parse(JSON.stringify(defaults)); // deep copy | |
var usedvalue = "used-" + (Math.random() * 99999) | |
// if no matching input was given, return the defaults | |
var match = input.match(start) | |
if(match === null) { | |
argv["success"] = false | |
argv["errors"] = { "required" : `Usage: ${usageString(start, defaults)}` } | |
return argv | |
} | |
// parse input for the parameters | |
var inputIndex = input.indexOf(start) | |
// console.log(`input: ${input}`) | |
// console.log(`inputIndex: ${inputIndex}`) | |
// console.log(`start.length: ${start.length}`) | |
// console.log(`inputIndex + start.length: ${inputIndex + start.length}`) | |
var inputOptions = input.substring(inputIndex + start.length).trim() | |
if(inputOptions.length > 0) { | |
// console.log(`inputOptions: ${inputOptions}`) | |
// console.log(`inputOptions.length: ${inputOptions.length}`) | |
var inputOptionsSplit = inputOptions.split(' ') | |
// console.log(`inputOptionsSplit: ${inputOptionsSplit}`) | |
// console.log(`inputOptionsSplit.length: ${inputOptionsSplit.length}`) | |
// console.log(`argv-1:`);console.log(argv) | |
// attempt to recombine options which might have multiterm default values | |
var recombinedOptions = [] | |
for(var i = 0; i < inputOptionsSplit.length; i++) { | |
var term = inputOptionsSplit[i] | |
if(term.startsWith('"') == false) { | |
recombinedOptions.push(term) | |
continue | |
} | |
if(term.endsWith('"') == false) { | |
var combined = term | |
while(term.endsWith('"') == false && i < inputOptionsSplit.length) { | |
i++ | |
term = inputOptionsSplit[i] | |
combined += " " + term | |
} | |
combined = combined.substring(1, combined.length - 1) // removes start and end quotes | |
recombinedOptions.push(combined) | |
} else { | |
recombinedOptions.push(term) | |
} | |
} | |
inputOptionsSplit = recombinedOptions | |
var currentIndex = 0 | |
for(var i = 0; i < inputOptionsSplit.length; i++) { | |
var term = inputOptionsSplit[i] | |
var key = "" | |
if(term.startsWith("-") || term.startsWith("--")) { | |
// so a named parameter was explicitely passed in, set the appropriate value by name | |
key = term | |
if(key.startsWith("--")) { key = key.substring(2) } | |
if(key.startsWith("-")) { key = key.substring(1) } | |
i++ | |
term = inputOptionsSplit[i] | |
var ki = keyIndex(defaults, key) | |
if(ki == currentIndex) currentIndex++ | |
} else { | |
var ki = keyIndex(defaults, term) | |
if(ki !== -1) { | |
// a named parameter was inferred as passed in, set the appropriate value by name | |
key = term | |
i++ | |
term = inputOptionsSplit[i] | |
var ki = keyIndex(defaults, key) | |
if(ki == currentIndex) currentIndex++ | |
} else { | |
// no named parameter was given, so just set the next value | |
key = keyAt(defaults, currentIndex) | |
currentIndex++ | |
while(used[key] == usedvalue) { // check the value hasn't already been set | |
key = keyAt(defaults, currentIndex) | |
currentIndex++ | |
} | |
} | |
} | |
argv[key] = term | |
used[key] = usedvalue | |
} | |
} | |
// validate required parameters were | |
var errors = {} | |
var failures = false | |
for(var d in defaults) { | |
if(defaults[d] == "required") { | |
var a = argv[d] | |
if(a == "required") { | |
var usage = usageString(start, defaults) | |
errors[a] = `'${d}' is a required parameter. Usage: ${usage}` | |
failures = true | |
} | |
} | |
} | |
argv["success"] = failures == false | |
argv["errors"] = errors | |
return argv | |
} | |
function keyIndex(object, key) { | |
var i = 0 | |
for(var current in object) { | |
if(current == key) return i | |
i++ | |
} | |
return -1 | |
} | |
function keyAt(object, index) { | |
var i = 0 | |
for(var current in object) { | |
if(i == index) return current | |
i++ | |
} | |
} | |
function usageString(start, defaults) { | |
var usage = "" | |
for(var d in defaults) { | |
var v = defaults[d] | |
var term = "" | |
if(v == "required") { | |
term = `<${d}>` | |
} else { | |
term = `[${d}:${v}]` | |
} | |
if(usage.length > 0) { usage += " " } | |
usage += term | |
} | |
if(!start.endsWith(" ")) { start += " " } | |
var result = start + usage | |
return result | |
} | |
function sendErrors(params, msg) { | |
errorMessage = "" | |
for(k in params.errors) { | |
if(errorMessage.length > 0) errorMessage += "\r\n" | |
errorMessage += params.errors[k] | |
} | |
errorMessage = "```" + errorMessage + "```" | |
msg.send(errorMessage) | |
} | |
function displayParams(params, hide) { | |
var display = "" | |
hide = hide || [] | |
if(hide.indexOf("success") === -1) hide.push("success") | |
if(hide.indexOf("errors") === -1) hide.push("errors") | |
for(var key in params) { | |
var hidden = false | |
for(var i = 0; i < hide.length; i++) { | |
var h = hide[i] | |
if(key == h) hidden = true | |
} | |
if(hidden) continue; | |
if(display.length > 0) display += ", " | |
display += `${key}:'${params[key]}'` | |
} | |
return display | |
} | |
module.exports = { parse: parse, sendErrors: sendErrors, displayParams: displayParams } | |
// function test(format, input, hide) { | |
// console.log("input: '" + input + "'") | |
// var results = parse(format, input) | |
// console.log(results) | |
// if(hide === undefined) | |
// console.log("displayParams: '" + displayParams(params) + "'") | |
// else | |
// console.log("displayParams: '" + displayParams(params, hide) + "'") | |
// } | |
// test("octo cancel {projectname:*} {releaseid:*}", "sysbot octo cancel gold") | |
// test("octo cancel gold 20", "octo cancel {projectname:*} {releaseid:*}") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --projectname gold") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --projectname gold 20") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel projectname gold 20") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --releaseid 20 gold") | |
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel releaseid 20 gold") | |
// test("octo cancel {projectname:*} {releaseid:*} {somethignrequired}", "octo cancel releaseid 20 gold") | |
// test(undefined, ["releaseid"], "octo cancel releaseid 20 gold") | |
// test("octo cancel {projectname:*} {releaseid:*}", "sysbot octo list") | |
// test("octo list {projectname:*} {releaseid:*}", "sysbot octo list") | |
// test("octo list {first:*} {second:*} {third:*} {fourth:*}", "sysbot octo list --first first-value -third 3 second-value fourth-value") | |
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "servicepro close 487156") | |
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "servicepro close 487156 \"a long description with a lot of terms\"") | |
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "sysbot servicepro close 487156 \"a long description with a lot of terms\"") | |
0 comments:
Post a Comment