aboutsummaryrefslogtreecommitdiff
path: root/commando
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2019-09-21 00:38:30 -0400
committerJakob Odersky <jakob@odersky.com>2019-09-22 14:58:51 -0400
commita5a2118e4e0f31a4b8ae9921fa634058af526cdc (patch)
tree3a8912601637c78c8995fb8403302e2f3971d323 /commando
parent8a71d804a41ffbd80f881fae24c42637e246afc2 (diff)
downloadcommando-a5a2118e4e0f31a4b8ae9921fa634058af526cdc.tar.gz
commando-a5a2118e4e0f31a4b8ae9921fa634058af526cdc.tar.bz2
commando-a5a2118e4e0f31a4b8ae9921fa634058af526cdc.zip
Migrate build to mill; redesign command structure and parser
Diffstat (limited to 'commando')
-rw-r--r--commando/src/Command.scala201
-rw-r--r--commando/src/Main.scala32
-rw-r--r--commando/test/src/CommandTest.scala221
3 files changed, 454 insertions, 0 deletions
diff --git a/commando/src/Command.scala b/commando/src/Command.scala
new file mode 100644
index 0000000..e0a7a5a
--- /dev/null
+++ b/commando/src/Command.scala
@@ -0,0 +1,201 @@
+package commando
+
+class Command(val name: String) {
+ import Command._
+ import collection.mutable
+
+ private case class Parameter(
+ var named: Boolean,
+ var name: String,
+ var aliases: List[String],
+ var argName: String,
+ var acceptsArg: Boolean,
+ var requiresArg: Boolean,
+ var required: Boolean,
+ var repeated: Boolean,
+ var action: Option[String] => Unit
+ )
+
+ class NamedBuilder(param: Parameter) {
+ def alias(aliases: String*) = { param.aliases ++= aliases; this }
+ def require() = { param.required = true; this }
+ def repeat() = { param.repeated = true; this }
+ def action(fct: () => Unit) = { param.action = opt => fct(); this }
+
+ def arg(name: String) = {
+ param.argName = name; param.acceptsArg = true; param.requiresArg = true;
+ new NamedArgBuilder(param)
+ }
+ def optionalArg(name: String) = {
+ param.argName = name; param.acceptsArg = true; param.requiresArg = false;
+ new NamedOptArgBuilder(param)
+ }
+ }
+
+ class NamedArgBuilder(param: Parameter) {
+ def alias(aliases: String*) = { param.aliases ++= aliases; this }
+ def require() = { param.required = true; this }
+ def repeat() = { param.repeated = true; this }
+
+ def action(fct: String => Unit) = {
+ param.action = opt => fct(opt.get); this
+ }
+ }
+ class NamedOptArgBuilder(param: Parameter) {
+ def alias(aliases: String*) = { param.aliases ++= aliases; this }
+ def require() = { param.required = true; this }
+ def repeat() = { param.repeated = true; this }
+
+ def action(fct: Option[String] => Unit) = { param.action = fct; this }
+ }
+
+ class PositionalBuilder(param: Parameter) {
+ def optional() = { param.required = false; this }
+ def repeat() = { param.repeated = true; param.required = false; this }
+
+ def action(fct: String => Unit) = {
+ param.action = opt => fct(opt.get); this
+ }
+ }
+
+ private val params = mutable.ListBuffer.empty[Parameter]
+
+ def named(name: String): NamedBuilder = {
+ val param =
+ Parameter(true, name, Nil, "", false, false, false, false, _ => ())
+ params += param
+ new NamedBuilder(param)
+ }
+
+ def positional(name: String): PositionalBuilder = {
+ val param =
+ Parameter(false, name, Nil, "", false, false, true, false, _ => ())
+ params += param
+ new PositionalBuilder(param)
+ }
+
+ def error(message: String): Nothing = throw new ParseError(message)
+
+ def parse(args: Iterable[String]): Option[String] =
+ try {
+ var (named, positional) = params.toList.partition(_.named)
+
+ // keeps track of which parameters have already been set
+ val seen: mutable.Set[Parameter] = mutable.Set.empty[Parameter]
+
+ val it = args.iterator
+ var arg = ""
+ var done = false
+ def next() = if (it.hasNext) arg = it.next() else done = true
+ next()
+
+ var escaping = false
+
+ def process(param: Parameter, value: Option[String]) = {
+ param.action(value)
+ }
+
+ def readPositional(arg: String) =
+ if (positional.isEmpty) {
+ error("too many arguments")
+ } else {
+ process(positional.head, Some(arg))
+ seen += positional.head
+ if (!positional.head.repeated) {
+ positional = positional.tail
+ }
+ }
+
+ def getNamed(name: String): Parameter = {
+ named.find(p => p.name == name || p.aliases.contains(name)) match {
+ case None => error(s"unknown parameter: '$name'")
+ case Some(param) if (!param.repeated && seen.contains(param)) =>
+ error(
+ s"parameter '$name' has already been given and repetitions are not allowed"
+ )
+ case Some(param) =>
+ seen += param
+ param
+ }
+ }
+
+ while (!done) {
+ if (escaping == true) {
+ readPositional(arg)
+ next()
+ } else if (arg == "--") {
+ escaping = true
+ next()
+ } else if (arg.startsWith("--")) {
+ arg.drop(2).split("=", 2) match {
+ case Array(name, embeddedValue) =>
+ val param = getNamed(name)
+ if (param.acceptsArg) {
+ process(param, Some(embeddedValue))
+ next()
+ } else {
+ error(s"parameter '$name' does not accept an argument")
+ }
+ case Array(name) =>
+ val param = getNamed(name)
+ next()
+ val nextIsArg = !done && (!arg.startsWith("-") || arg == "--")
+
+ if (param.requiresArg && nextIsArg) {
+ process(param, Some(arg))
+ next()
+ } else if (param.requiresArg && !nextIsArg) {
+ error(s"parameter '$name' requires an argument")
+ } else if (param.acceptsArg && nextIsArg) {
+ process(param, Some(arg))
+ next()
+ } else {
+ process(param, None)
+ }
+ }
+ } else {
+ readPositional(arg)
+ next()
+ }
+ }
+
+ for (param <- params) {
+ if (param.required && !seen.contains(param))
+ error(s"missing parameter: '${param.name}'")
+ }
+ None
+ } catch {
+ case ParseError(message) => Some(message)
+ }
+
+ def completion(): String = {
+ val completions: List[String] = params.toList.filter(_.named).flatMap {
+ param =>
+ if (param.requiresArg) {
+ List(s"--${param.name}=")
+ } else if (param.acceptsArg) {
+ List(s"--${param.name}", s"--${param.name}=")
+ } else {
+ List(s"--${param.name}")
+ }
+ }
+
+ s"""|_${name}_complete() {
+ | local cur_word param_list
+ | cur_word="$${COMP_WORDS[COMP_CWORD]}"
+ | param_list="${completions.mkString(" ")}"
+ | if [[ $${cur_word} == -* ]]; then
+ | COMPREPLY=( $$(compgen -W "$$param_list" -- $${cur_word}) )
+ | else
+ | COMPREPLY=()
+ | fi
+ | return 0
+ |}
+ |complete -F _${name}_complete ${name}
+ |""".stripMargin
+ }
+
+}
+object Command {
+ case class ParseError(message: String) extends RuntimeException(message)
+}
diff --git a/commando/src/Main.scala b/commando/src/Main.scala
new file mode 100644
index 0000000..1e1b786
--- /dev/null
+++ b/commando/src/Main.scala
@@ -0,0 +1,32 @@
+package example
+
+object Main extends App {
+
+ val cmd = new commando.Command("xorc") {
+ val version = named("version")
+ .action(() => println("version 1"))
+ .repeat()
+
+ named("verbose")
+ .optionalArg("level")
+ .action(level => println(s"level $level"))
+
+ positional("FILES")
+ .action { s =>
+ val f = new java.io.File(s)
+ if (!f.exists()) error(s"File $s does not exit")
+ println(f)
+ }
+ .repeat()
+
+ val comp = named("completion")
+ .action(() => println(completion()))
+ }
+ cmd.parse(args) match {
+ case None =>
+ case Some(error) =>
+ println(error)
+ sys.exit(1)
+ }
+
+}
diff --git a/commando/test/src/CommandTest.scala b/commando/test/src/CommandTest.scala
new file mode 100644
index 0000000..13897ee
--- /dev/null
+++ b/commando/test/src/CommandTest.scala
@@ -0,0 +1,221 @@
+package commando
+
+import utest._
+
+object CommandTests extends TestSuite {
+
+ def eval(cmd: Command, args: List[String]): Boolean = {
+ cmd.parse(args).isEmpty
+ }
+
+ val tests = Tests {
+ "empty" - {
+ val cmd = new Command("cmd")
+ assert(eval(cmd, Nil))
+ assert(!eval(cmd, "a" :: Nil))
+ }
+ "positional required" - {
+ val cmd = new Command("cmd") {
+ positional("POS")
+ }
+ assert(!eval(cmd, Nil)) // no param
+ assert(eval(cmd, "" :: Nil)) // empty param
+ assert(eval(cmd, "a" :: Nil)) // one param
+ assert(!eval(cmd, "a" :: "b" :: Nil)) // too many params
+ assert(!eval(cmd, "a" :: "b" :: "c" :: Nil)) // too many params
+ }
+ "positional two" - {
+ val cmd = new Command("cmd") {
+ positional("one")
+ positional("two")
+ }
+ assert(!eval(cmd, Nil)) // no param
+ assert(!eval(cmd, "" :: Nil)) // empty param
+ assert(!eval(cmd, "a" :: Nil)) // one param
+ assert(eval(cmd, "a" :: "b" :: Nil)) // too many params
+ assert(!eval(cmd, "a" :: "b" :: "c" :: Nil)) // too many params
+ }
+ "positional repeated" - {
+ val cmd = new Command("cmd") {
+ positional("one").repeat()
+ }
+ assert(eval(cmd, Nil))
+ assert(eval(cmd, "" :: Nil))
+ assert(eval(cmd, "a" :: Nil))
+ assert(eval(cmd, "a" :: "b" :: Nil))
+ assert(eval(cmd, "a" :: "b" :: "c" :: Nil))
+ }
+ "positional optional" - {
+ val cmd = new Command("cmd") {
+ positional("one").optional()
+ }
+ assert(eval(cmd, Nil))
+ assert(eval(cmd, "" :: Nil))
+ assert(eval(cmd, "a" :: Nil))
+ assert(!eval(cmd, "a" :: "b" :: Nil))
+ assert(!eval(cmd, "a" :: "b" :: "c" :: Nil))
+ }
+ "positional combination" - {
+ class Cmd extends Command("cmd") {
+ var one: Option[String] = None
+ var two: Option[String] = None
+ positional("one")
+ .optional()
+ .action(v => one = Some(v))
+ positional("two")
+ .repeat()
+ .action(v => two = Some(v))
+ }
+
+ val cmd1 = new Cmd
+ assert(eval(cmd1, Nil))
+ assert(cmd1.one == None && cmd1.two == None)
+
+ val cmd2 = new Cmd
+ assert(eval(cmd2, "" :: Nil))
+ assert(cmd2.one == Some("") && cmd2.two == None)
+
+ val cmd3 = new Cmd
+ assert(eval(cmd3, "a" :: Nil))
+ assert(cmd3.one == Some("a") && cmd3.two == None)
+
+ val cmd4 = new Cmd
+ assert(eval(cmd4, "a" :: "b" :: Nil))
+ assert(cmd4.one == Some("a") && cmd4.two == Some("b"))
+
+ val cmd5 = new Cmd
+ assert(eval(cmd5, "a" :: "b" :: "c" :: Nil))
+ assert(cmd5.one == Some("a") && cmd5.two == Some("c"))
+ }
+ "named flag optional" - {
+ val cmd = new Command("cmd") {
+ named("param")
+ }
+ assert(eval(cmd, Nil))
+ assert(!eval(cmd, "" :: Nil))
+ assert(eval(cmd, "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "" :: Nil))
+ assert(!eval(cmd, "--param" :: "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "--param" :: "a" :: Nil))
+ }
+ "named flag required" - {
+ val cmd = new Command("cmd") {
+ named("param").require()
+ }
+ assert(!eval(cmd, Nil))
+ assert(!eval(cmd, "" :: Nil))
+ assert(eval(cmd, "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "" :: Nil))
+ assert(!eval(cmd, "--param" :: "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "--param" :: "a" :: Nil))
+ }
+ "named flag repeated" - {
+ val cmd = new Command("cmd") {
+ named("param").repeat()
+ }
+ assert(eval(cmd, Nil))
+ assert(!eval(cmd, "" :: Nil))
+ assert(eval(cmd, "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "" :: Nil))
+ assert(eval(cmd, "--param" :: "--param" :: Nil))
+ assert(!eval(cmd, "--param" :: "--param" :: "a" :: Nil))
+ }
+ "named arg" - {
+ class Cmd extends Command("cmd") {
+ var one: Option[String] = None
+ var two: Option[String] = None
+ named("one")
+ .arg("value")
+ .action(v => one = Some(v))
+ positional("two")
+ .action(v => two = Some(v))
+ }
+
+ val cmd1 = new Cmd
+ assert(!eval(cmd1, Nil))
+ assert(cmd1.one == None && cmd1.two == None)
+
+ val cmd2 = new Cmd
+ assert(eval(cmd2, "--one=a" :: "b" :: Nil))
+ assert(cmd2.one == Some("a") && cmd2.two == Some("b"))
+
+ val cmd3 = new Cmd
+ assert(eval(cmd3, "b" :: "--one=a" :: Nil))
+ assert(cmd3.one == Some("a") && cmd3.two == Some("b"))
+
+ val cmd4 = new Cmd
+ assert(eval(cmd4, "--one" :: "a" :: "b" :: Nil))
+ assert(cmd4.one == Some("a") && cmd4.two == Some("b"))
+
+ val cmd5 = new Cmd
+ assert(eval(cmd5, "b" :: "--one" :: "a" :: Nil))
+ assert(cmd5.one == Some("a") && cmd5.two == Some("b"))
+
+ val cmd6 = new Cmd
+ assert(!eval(cmd6, "--one" :: "--a" :: "b" :: Nil))
+
+ val cmd7 = new Cmd
+ assert(!eval(cmd7, "b" :: "--one" :: Nil))
+
+ val cmd8 = new Cmd
+ assert(!eval(cmd8, "a" :: "--" :: "--one" :: Nil))
+
+ val cmd9 = new Cmd
+ assert(!eval(cmd9, "--one" :: "--" :: "--one" :: "b" :: Nil))
+
+ val cmd10 = new Cmd
+ assert(eval(cmd10, "--one=--ab" :: "b" :: Nil))
+ assert(cmd10.one == Some("--ab") && cmd10.two == Some("b"))
+ }
+ "named arg optional" - {
+ class Cmd extends Command("cmd") {
+ var one: Option[String] = None
+ var two: Option[String] = None
+ named("one")
+ .optionalArg("value")
+ .action(v => one = v)
+ positional("two")
+ .action(v => two = Some(v))
+ }
+
+ val cmd1 = new Cmd
+ assert(!eval(cmd1, Nil))
+ assert(cmd1.one == None && cmd1.two == None)
+
+ val cmd2 = new Cmd
+ assert(eval(cmd2, "--one=a" :: "b" :: Nil))
+ assert(cmd2.one == Some("a") && cmd2.two == Some("b"))
+
+ val cmd3 = new Cmd
+ assert(eval(cmd3, "b" :: "--one=a" :: Nil))
+ assert(cmd3.one == Some("a") && cmd3.two == Some("b"))
+
+ val cmd4 = new Cmd
+ assert(eval(cmd4, "--one" :: "a" :: "b" :: Nil))
+ assert(cmd4.one == Some("a") && cmd4.two == Some("b"))
+
+ val cmd5 = new Cmd
+ assert(eval(cmd5, "b" :: "--one" :: "a" :: Nil))
+ assert(cmd5.one == Some("a") && cmd5.two == Some("b"))
+
+ val cmd6 = new Cmd
+ assert(!eval(cmd6, "--one" :: "--a" :: "b" :: Nil))
+
+ val cmd7 = new Cmd
+ assert(eval(cmd7, "b" :: "--one" :: Nil))
+ assert(cmd7.one == None && cmd7.two == Some("b"))
+
+ val cmd8 = new Cmd
+ assert(eval(cmd8, "--" :: "--one" :: Nil))
+ assert(cmd8.one == None && cmd8.two == Some("--one"))
+
+ val cmd9 = new Cmd
+ assert(eval(cmd9, "--one" :: "a" :: "--" :: "--one" :: Nil))
+ assert(cmd9.one == Some("a") && cmd9.two == Some("--one"))
+
+ val cmd10 = new Cmd
+ assert(eval(cmd10, "--one=--ab" :: "b" :: Nil))
+ assert(cmd10.one == Some("--ab") && cmd10.two == Some("b"))
+ }
+ }
+}