Yet Another Command Line Parser for Slack/Hubot

on Monday, January 14, 2019

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


Creative Commons License
This site uses Alex Gorbatchev's SyntaxHighlighter, and hosted by herdingcode.com's Jon Galloway.