From 0a24a3c4be77ddbcd65e83d23837ed29be0d731e Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Sat, 28 Apr 2018 17:09:16 -0700 Subject: Initial commit --- .gitignore | 1 + build.sbt | 6 + project/build.properties | 1 + project/plugins.sbt | 1 + src/main/scala/completion/Bash.scala | 107 ++++++++++++++++++ src/main/scala/definitions.scala | 73 ++++++++++++ src/main/scala/package.scala | 14 +++ src/main/scala/parsing.scala | 203 ++++++++++++++++++++++++++++++++++ src/test/scala/CmdTest.scala | 207 +++++++++++++++++++++++++++++++++++ 9 files changed, 613 insertions(+) create mode 100644 .gitignore create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 src/main/scala/completion/Bash.scala create mode 100644 src/main/scala/definitions.scala create mode 100644 src/main/scala/package.scala create mode 100644 src/main/scala/parsing.scala create mode 100644 src/test/scala/CmdTest.scala 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) + } + } +} -- cgit v1.2.3