aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2018-04-28 17:09:16 -0700
committerJakob Odersky <jakob@odersky.com>2018-04-28 17:09:16 -0700
commit0a24a3c4be77ddbcd65e83d23837ed29be0d731e (patch)
treeef70ccf2d6a01bbd49df7907ee1e0a2b25c335d3
downloadcommando-0a24a3c4be77ddbcd65e83d23837ed29be0d731e.tar.gz
commando-0a24a3c4be77ddbcd65e83d23837ed29be0d731e.tar.bz2
commando-0a24a3c4be77ddbcd65e83d23837ed29be0d731e.zip
Initial commit
-rw-r--r--.gitignore1
-rw-r--r--build.sbt6
-rw-r--r--project/build.properties1
-rw-r--r--project/plugins.sbt1
-rw-r--r--src/main/scala/completion/Bash.scala107
-rw-r--r--src/main/scala/definitions.scala73
-rw-r--r--src/main/scala/package.scala14
-rw-r--r--src/main/scala/parsing.scala203
-rw-r--r--src/test/scala/CmdTest.scala207
9 files changed, 613 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f97022
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+target/ \ No newline at end of file
diff --git a/build.sbt b/build.sbt
new file mode 100644
index 0000000..423d09a
--- /dev/null
+++ b/build.sbt
@@ -0,0 +1,6 @@
+scalaVersion := "2.12.5"
+
+libraryDependencies ++= Seq(
+ "com.lihaoyi" %% "utest" % "0.6.3" % "test"
+)
+testFrameworks += new TestFramework("utest.runner.Framework")
diff --git a/project/build.properties b/project/build.properties
new file mode 100644
index 0000000..64cf32f
--- /dev/null
+++ b/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.1.4
diff --git a/project/plugins.sbt b/project/plugins.sbt
new file mode 100644
index 0000000..f86e373
--- /dev/null
+++ b/project/plugins.sbt
@@ -0,0 +1 @@
+addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1")
diff --git a/src/main/scala/completion/Bash.scala b/src/main/scala/completion/Bash.scala
new file mode 100644
index 0000000..0ba3440
--- /dev/null
+++ b/src/main/scala/completion/Bash.scala
@@ -0,0 +1,107 @@
+package cmd
+package completion
+
+object Bash {
+
+ private def addCommands(command: Command): String = {
+ command.commands.map(c => s"""commands+=("$c")\n""").mkString
+ }
+
+ private def addFlags(command: Command): String = {
+ command.options.map { opt =>
+ val extra = if (opt.argumentRequired) "=" else ""
+ val short = opt.short.map(c => s"""flags+=("-${c}")""").getOrElse("")
+ s"""|flags+=("--${opt.long}$extra")
+ |$short
+ |""".stripMargin
+ }.mkString
+ }
+
+ private def commandBlocks(previous: String,
+ commands: Set[Command]): String = {
+ def block(previous: String, command: Command): String = {
+ s"""_${previous}_${command.name}() {
+ | ${addCommands(command)}
+ | ${addFlags(command)}
+ | true
+ |}
+ |""".stripMargin
+ }
+
+ if (commands.isEmpty) {
+ ""
+ } else {
+ commands.map { cmd =>
+ block(previous, cmd) + commandBlocks(cmd.name, cmd.commands)
+ }.mkString
+ }
+ }
+
+ def completion(command: Command) = {
+ val name = command.name
+
+ s"""__${name}_contains_word() {
+ | local word="$$1"; shift
+ | for w in "$$@"; do
+ | [[ $$w = "$$word" ]] && return 0
+ | done
+ | return 1
+ |}
+ |
+ |__${name}_handle_reply() {
+ | case "$$cur" in
+ | -*)
+ | COMPREPLY=( $$(compgen -W "$${flags[*]}" -- "$$cur") )
+ | if [[ $${#COMPREPLY[@]} -eq 1 ]] && [[ $${COMPREPLY[0]} == *= ]]; then
+ | compopt -o nospace
+ | else
+ | compopt +o nospace
+ | fi
+ | ;;
+ | *)
+ | COMPREPLY=( $$(compgen -W "$${commands[*]}" -- "$$cur") )
+ | ;;
+ | esac
+ |}
+ |
+ |__${name}_handle_word() {
+ | if [[ $$c -ge $$cword ]]; then
+ | __${name}_handle_reply
+ | return
+ | fi
+ | if __${name}_contains_word "$${words[c]}" "$${commands[@]}"; then
+ | local next_command="$${last_command}_$${words[c]}"
+ | last_command="$$next_command"
+ | commands=()
+ | flags=()
+ | $$next_command
+ | fi
+ | c=$$((c+1))
+ | __${name}_handle_word
+ |}
+ |
+ |${commandBlocks(name, command.commands)}
+ |
+ |__${name}_start() {
+ | local words=("$${COMP_WORDS[@]}")
+ | local cword="$$COMP_CWORD"
+ | local cur="$${COMP_WORDS[COMP_CWORD]}"
+ | local c=0
+ | COMPREPLY=()
+ |
+ | local last_command="_${name}"
+ | local commands=()
+ | local flags=()
+ |
+ | ${addCommands(command)}
+ | ${addFlags(command)}
+ |
+ | __${name}_handle_word
+ |
+ | return 0
+ |}
+ |complete -o default -F __${name}_start ${name}
+ |""".stripMargin
+ }
+
+}
diff --git a/src/main/scala/definitions.scala b/src/main/scala/definitions.scala
new file mode 100644
index 0000000..5878b47
--- /dev/null
+++ b/src/main/scala/definitions.scala
@@ -0,0 +1,73 @@
+package cmd
+
+import scala.{Option => Maybe}
+
+sealed trait Definition
+
+case class Option(long: String,
+ short: Maybe[Char] = None,
+ parameter: Maybe[Parameter] = None)
+ extends Definition {
+ def argumentAllowed: Boolean = parameter.isDefined
+ def argumentRequired: Boolean = parameter.map(_.required).getOrElse(false)
+ override def toString = {
+ val shortString = short.map(c => s"-$c|").getOrElse("")
+ val argString = parameter match {
+ case None => ""
+ case Some(Parameter(argName, false)) => s"[=<$argName>]"
+ case Some(Parameter(argName, true)) => s"=<$argName>"
+ }
+ s"[$shortString--$long$argString]"
+ }
+}
+
+case class Parameter(
+ name: String,
+ required: Boolean = true
+) extends Definition {
+ override def toString = if (required) s"<$name>" else s"[<$name>]"
+}
+
+case class Command(
+ name: String,
+ options: Set[Option] = Set.empty,
+ parameters: Seq[Parameter] = Seq.empty,
+ commands: Set[Command] = Set.empty
+) extends Definition {
+ override def toString = name
+
+ def subusage(level: Int): String = {
+ val optionStrings = options.map { opt =>
+ opt.toString
+ }
+ val parameterStrings = parameters map { param =>
+ param.toString
+ }
+ val commandStrings = Seq(commands.map(cmd => cmd.name).mkString("|"))
+
+ val headline =
+ (Seq(name) ++ optionStrings ++ parameterStrings ++ commandStrings)
+ .mkString(" ")
+ val sublines = commands
+ .map(_.subusage(level + 1))
+ .map(line => " " * (level + 1) + line)
+ headline + sublines.mkString("\n", "", "")
+ }
+
+ def usage: String = "Usage: " + subusage(0)
+
+ def completion: String = cmd.completion.Bash.completion(this)
+
+}
+object Command {
+
+ def apply(name: String, defs: Definition*): Command = {
+ Command(
+ name,
+ options = defs.collect { case opt: Option => opt }.toSet,
+ parameters = defs.collect { case param: Parameter => param }.toSeq,
+ commands = defs.collect { case cmd: Command => cmd }.toSet
+ )
+ }
+
+}
diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala
new file mode 100644
index 0000000..97d366c
--- /dev/null
+++ b/src/main/scala/package.scala
@@ -0,0 +1,14 @@
+package cmd
+
+object `package` {
+ def parse(command: Command,
+ arguments: Seq[String]): Either[ParseException, CommandLine] =
+ Parser.parse(command, arguments)
+ def parseOrExit(command: Command, arguments: Seq[String])(
+ action: CommandLine => Any): Unit = parse(command, arguments) match {
+ case Left(ex) =>
+ System.err.println(ex.getMessage)
+ System.exit(1)
+ case Right(res) => action(res)
+ }
+}
diff --git a/src/main/scala/parsing.scala b/src/main/scala/parsing.scala
new file mode 100644
index 0000000..05c5c51
--- /dev/null
+++ b/src/main/scala/parsing.scala
@@ -0,0 +1,203 @@
+package cmd
+import scala.collection.mutable
+import scala.{Option => Maybe}
+
+case class CommandLine(
+ command: String,
+ arguments: Map[String, String],
+ subcommand: Maybe[CommandLine]
+)
+
+class ParseException(message: String) extends RuntimeException(message)
+
+object Parser {
+ private sealed trait TokenKind
+ private case object SHORT extends TokenKind
+ private case object LONG extends TokenKind
+ private case object POSITIONAL extends TokenKind
+ private case object DOUBLE_DASH extends TokenKind
+ private case object EOL extends TokenKind
+
+ private case class Token(value: String, kind: TokenKind)
+
+ private def lex(input: Seq[String]) = new Iterator[Token] {
+ val args = input.iterator
+ val shortOptions = new mutable.Queue[Token]
+
+ var escaping = false
+ def hasNext = args.hasNext || !shortOptions.isEmpty
+
+ def next(): Token =
+ if (!shortOptions.isEmpty) {
+ shortOptions.dequeue()
+ } else {
+ val arg = args.next
+
+ if (escaping) {
+ Token(arg, POSITIONAL)
+ } else if (arg == "--") {
+ escaping = true
+ Token("--", DOUBLE_DASH)
+ } else if (arg.startsWith("--")) {
+ Token(arg.drop(2), LONG)
+ } else if (arg.startsWith("-") && arg != "-") {
+ arg.drop(1).foreach { char =>
+ shortOptions.enqueue(Token(char.toString, SHORT))
+ }
+ next()
+ } else {
+ Token(arg, POSITIONAL)
+ }
+ }
+ }
+
+ def parse(command: Command,
+ args: Seq[String]): Either[ParseException, CommandLine] =
+ try {
+ val tokens: Iterator[Token] = lex(args)
+ var token: Token = Token("end-of-line", EOL)
+ def readToken(): Unit = {
+ if (tokens.hasNext) {
+ token = tokens.next
+ } else {
+ token = Token("end-of-line", EOL)
+ }
+ }
+
+ def accept(): Token = {
+ val tok = token
+ readToken()
+ tok
+ }
+
+ def line(command: Command): CommandLine = {
+ val longs: Map[String, Option] = command.options.map {
+ case opt => opt.long -> opt
+ }.toMap
+ val shorts: Map[String, Option] = command.options.collect {
+ case opt if opt.short.isDefined => opt.short.get.toString -> opt
+ }.toMap
+ val subcommands: Map[String, Command] = command.commands.map {
+ case cmd => cmd.name -> cmd
+ }.toMap
+
+ def fatal(message: String) =
+ throw new ParseException(s"${command.name}: $message")
+
+ def option(): (String, String) = {
+ val tok = accept()
+ val parts = tok.value.split("=", 2)
+ val name = parts(0)
+ val embedded: Maybe[String] =
+ if (parts.size > 1) Some(parts(1)) else None
+ val opt = (tok.kind: @unchecked) match {
+ case LONG =>
+ longs.getOrElse(name, fatal(s"option --$name unknown"))
+ case SHORT =>
+ shorts.getOrElse(name, fatal(s"option -$name unknown"))
+ }
+
+ if (opt.argumentRequired) {
+ embedded match {
+ case Some(value) => opt.long -> value
+ case None if token.kind == POSITIONAL =>
+ opt.long -> accept().value
+ case None =>
+ fatal(
+ s"option ${opt} requires an argument but ${token.value} found")
+ }
+ } else if (opt.argumentAllowed) {
+ embedded match {
+ case Some(value) => opt.long -> value
+ case None =>
+ if (token.kind == POSITIONAL) {
+ opt.long -> accept.value
+ } else {
+ opt.long -> ""
+ }
+ }
+ } else { // no argument allowed
+ embedded match {
+ case Some(value) =>
+ fatal(
+ s"no argument allowed for option $opt (it is set to $value)")
+ case None => opt.long -> ""
+ }
+ }
+ }
+
+ val remainingParameters = command.parameters.iterator
+ def parameter(): (String, String) = {
+ if (remainingParameters.hasNext) {
+ remainingParameters.next.name -> accept().value
+ } else {
+ fatal(s"too many parameters: '${token.value}'")
+ }
+ }
+
+ val parsedOptions = new mutable.HashMap[String, String]
+ val parsedParameters = new mutable.HashMap[String, String]
+
+ var escaping = false
+
+ def check(subline: Maybe[CommandLine]): CommandLine = {
+ val remaining = remainingParameters.toList
+ if (!remaining.forall(_.required == false)) {
+ val missing = remaining.toList.map(p => s"'${p.name}'")
+ fatal(s"missing parameter(s) ${missing.mkString(", ")}")
+ } else if (!subcommands.isEmpty && subline.isEmpty) {
+ val missing = command.commands.map(c => s"'${c.name}'")
+ fatal(
+ s"subcommand not specified (must be either one of ${missing.mkString(", ")})")
+ } else {
+ CommandLine(command.name,
+ parsedOptions.toMap ++ parsedParameters.toMap,
+ subline)
+ }
+ }
+
+ @annotation.tailrec
+ def innerLine(): CommandLine = {
+ if (token.kind == EOL) {
+ check(None)
+ } else if (escaping) {
+ parsedParameters += parameter()
+ innerLine()
+ } else if (token.kind == DOUBLE_DASH) {
+ escaping = true
+ readToken()
+ innerLine()
+ } else if (token.kind == POSITIONAL && !remainingParameters.isEmpty) {
+ parsedParameters += parameter()
+ innerLine()
+ } else if (token.kind == POSITIONAL) {
+ if (subcommands.isEmpty) {
+ fatal(s"too many parameters: '${token.value}'")
+ } else {
+ subcommands.get(token.value) match {
+ case None =>
+ val cmds = command.commands.map(c => s"'${c.name}'")
+ fatal(
+ s"subcommand '${token.value}' not found (must be one of ${cmds
+ .mkString(", ")})")
+ case Some(_) =>
+ val subline = line(subcommands(accept().value))
+ check(Some(subline))
+ }
+ }
+ } else if (token.kind == LONG || token.kind == SHORT) {
+ parsedOptions += option()
+ innerLine()
+ } else {
+ fatal(s"unknown token $token")
+ }
+ }
+ innerLine()
+ }
+ readToken()
+ Right(line(command))
+ } catch {
+ case ex: ParseException => Left(ex)
+ }
+
+}
diff --git a/src/test/scala/CmdTest.scala b/src/test/scala/CmdTest.scala
new file mode 100644
index 0000000..d049812
--- /dev/null
+++ b/src/test/scala/CmdTest.scala
@@ -0,0 +1,207 @@
+package cmd
+
+import utest._
+
+object CmdTests extends TestSuite {
+
+ val cbx = cmd.Command(
+ "cbx",
+ cmd.Option("server", Some('s'), Some(cmd.Parameter("name"))),
+ cmd.Command(
+ "version",
+ cmd.Option("verbose", Some('v'), Some(cmd.Parameter("k=v", false)))),
+ cmd.Command("login",
+ cmd.Parameter("server_url"),
+ cmd.Parameter("username", false),
+ cmd.Parameter("password", false)),
+ cmd.Command("run",
+ cmd.Option("file", Some('f'), Some(cmd.Parameter("file_name"))),
+ cmd.Option("force", None),
+ cmd.Parameter("pipeline", false)),
+ cmd.Command("level1",
+ cmd.Command("level2-1",
+ cmd.Parameter("p2"),
+ cmd.Command("level3", cmd.Parameter("p3"))),
+ cmd.Command("level2-2"))
+ )
+
+ def parse(in: String): CommandLine = cmd.parse(cbx, in.split(" ").tail) match {
+ case Left(ex) => throw ex
+ case Right(res) => res
+ }
+
+ def shouldFail(in: String) =
+ assert(cmd.parse(cbx, in.split(" ").tail).isLeft)
+
+ val tests = Tests {
+ "printUsage" - {
+ cbx.usage
+ }
+ "simple" - {
+ assert(
+ parse("cbx version").subcommand.get == CommandLine("version",
+ Map.empty,
+ None))
+ }
+ "emptyAllowedOption" - {
+ assert(
+ parse("cbx version -v").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> ""),
+ None))
+ assert(
+ parse("cbx version --verbose").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> ""),
+ None))
+ }
+ "setAllowedOption" - {
+ assert(
+ parse("cbx version -v x").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x"),
+ None))
+ assert(
+ parse("cbx version --verbose x").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x"),
+ None))
+ assert(
+ parse("cbx version --verbose=x").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x"),
+ None))
+ assert(
+ parse("cbx version --verbose=x=y").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x=y"),
+ None))
+ assert(
+ parse("cbx version --verbose=x=y,z=w").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x=y,z=w"),
+ None))
+ assert(
+ parse("cbx version --verbose x=y,z=w").subcommand.get == CommandLine(
+ "version",
+ Map("verbose" -> "x=y,z=w"),
+ None))
+ shouldFail("cbx version --verbose x=y z=w")
+ }
+ "requiredArgOption" - {
+ assert(parse("cbx run").subcommand.get == CommandLine("run", Map(), None)) // make sure it works first
+ assert(
+ parse("cbx run -f x").subcommand.get == CommandLine("run",
+ Map("file" -> "x"),
+ None))
+ assert(
+ parse("cbx run --file x").subcommand.get == CommandLine(
+ "run",
+ Map("file" -> "x"),
+ None))
+ assert(
+ parse("cbx run --file=x").subcommand.get == CommandLine(
+ "run",
+ Map("file" -> "x"),
+ None))
+ assert(
+ parse("cbx run --file=x=y,z=w").subcommand.get == CommandLine(
+ "run",
+ Map("file" -> "x=y,z=w"),
+ None))
+ shouldFail("cbx run --file")
+ shouldFail("cbx run --file --")
+ }
+ "noArgOption" - {
+ shouldFail("cbx run --force=x")
+ assert(
+ parse("cbx run --force x").subcommand.get == CommandLine(
+ "run",
+ Map("force" -> "", "pipeline" -> "x"),
+ None))
+ }
+ "globalOption" - {
+ assert(parse("cbx --server run run").arguments == Map("server" -> "run"))
+ assert(
+ parse("cbx --server run run").subcommand.get == CommandLine("run",
+ Map.empty,
+ None))
+ assert(parse("cbx -s run run").arguments == Map("server" -> "run"))
+ assert(
+ parse("cbx -s run run").subcommand.get == CommandLine("run",
+ Map.empty,
+ None))
+ assert(parse("cbx --server=run run").arguments == Map("server" -> "run"))
+ assert(
+ parse("cbx --server=run run").subcommand.get == CommandLine("run",
+ Map.empty,
+ None))
+ shouldFail("cbx -x run")
+ shouldFail("cbx --x run")
+ }
+ "parameter" - {
+ assert(
+ parse("cbx login x").subcommand.get == CommandLine(
+ "login",
+ Map("server_url" -> "x"),
+ None))
+ assert(
+ parse("cbx login x y").subcommand.get == CommandLine(
+ "login",
+ Map("server_url" -> "x", "username" -> "y"),
+ None))
+ assert(
+ parse("cbx login x y z").subcommand.get == CommandLine(
+ "login",
+ Map("server_url" -> "x", "username" -> "y", "password" -> "z"),
+ None))
+ shouldFail("cbx login - y z w")
+ assert(
+ parse("cbx login - y").subcommand.get == CommandLine(
+ "login",
+ Map("server_url" -> "-", "username" -> "y"),
+ None))
+ }
+ "outOfOrderOptions" - {
+ assert(
+ parse("cbx run --force pipelinename -f x").subcommand.get == CommandLine(
+ "run",
+ Map("force" -> "", "pipeline" -> "pipelinename", "file" -> "x"),
+ None))
+ assert(
+ parse("cbx run --force -- -f").subcommand.get == CommandLine(
+ "run",
+ Map("force" -> "", "pipeline" -> "-f"),
+ None))
+ assert(
+ parse("cbx run --force -- --file").subcommand.get == CommandLine(
+ "run",
+ Map("force" -> "", "pipeline" -> "--file"),
+ None))
+ assert(
+ parse("cbx run --force -- --").subcommand.get == CommandLine(
+ "run",
+ Map("force" -> "", "pipeline" -> "--"),
+ None))
+ shouldFail("cbx run --force -- -f x") // too many parameters
+ }
+ "nested1" - {
+ val line = parse("cbx level1 level2-1 x=y level3 z").subcommand.get
+ val expected = CommandLine(
+ "level1",
+ Map.empty,
+ Some(
+ CommandLine("level2-1",
+ Map("p2" -> "x=y"),
+ Some(CommandLine("level3", Map("p3" -> "z"), None)))))
+ assert(line == expected)
+ }
+ "nested2" - {
+ val line = parse("cbx level1 level2-2 --").subcommand.get
+ val expected = CommandLine("level1",
+ Map.empty,
+ Some(CommandLine("level2-2", Map.empty, None)))
+ assert(line == expected)
+ }
+ }
+}