From a5a2118e4e0f31a4b8ae9921fa634058af526cdc Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Sat, 21 Sep 2019 00:38:30 -0400 Subject: Migrate build to mill; redesign command structure and parser --- .gitignore | 2 +- build.sbt | 32 ----- build.sc | 13 +++ commando/src/Command.scala | 201 +++++++++++++++++++++++++++++++ commando/src/Main.scala | 32 +++++ commando/test/src/CommandTest.scala | 221 +++++++++++++++++++++++++++++++++++ project/build.properties | 1 - project/plugins.sbt | 6 - project/publish.sbt | 2 - publish.sbt | 29 ----- src/main/scala/completion/Bash.scala | 107 ----------------- src/main/scala/definitions.scala | 66 ----------- src/main/scala/package.scala | 50 -------- src/main/scala/parsing.scala | 212 --------------------------------- src/test/scala/ParserTest.scala | 143 ----------------------- 15 files changed, 468 insertions(+), 649 deletions(-) delete mode 100644 build.sbt create mode 100644 build.sc create mode 100644 commando/src/Command.scala create mode 100644 commando/src/Main.scala create mode 100644 commando/test/src/CommandTest.scala delete mode 100644 project/build.properties delete mode 100644 project/plugins.sbt delete mode 100644 project/publish.sbt delete mode 100644 publish.sbt delete mode 100644 src/main/scala/completion/Bash.scala delete mode 100644 src/main/scala/definitions.scala delete mode 100644 src/main/scala/package.scala delete mode 100644 src/main/scala/parsing.scala delete mode 100644 src/test/scala/ParserTest.scala diff --git a/.gitignore b/.gitignore index 9f97022..89f9ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -target/ \ No newline at end of file +out/ diff --git a/build.sbt b/build.sbt deleted file mode 100644 index 152be51..0000000 --- a/build.sbt +++ /dev/null @@ -1,32 +0,0 @@ -// shadow sbt-scalajs' crossProject and CrossType until Scala.js 1.0.0 is released -import sbtcrossproject.{crossProject, CrossType} - -lazy val commando = crossProject(JSPlatform, JVMPlatform, NativePlatform) - .withoutSuffixFor(JVMPlatform) - .crossType(CrossType.Pure) - .in(file(".")) - .settings( - scalacOptions ++= Seq( - "-deprecation", - "-feature" - ), - libraryDependencies ++= Seq( - "com.lihaoyi" %%% "utest" % "0.6.6" % "test" - ), - testFrameworks += new TestFramework("utest.runner.Framework"), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := "2.12.8" :: "2.11.12" :: Nil - ) - .jvmSettings( - crossScalaVersions := "2.12.8" :: "2.11.12" :: Nil - ) - .nativeSettings( - crossScalaVersions := "2.11.12" :: Nil, - nativeLinkStubs := true - ) - -lazy val commandoJS = commando.js -lazy val commandoJVM = commando.jvm -lazy val commandoNative = commando.native diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..e27039f --- /dev/null +++ b/build.sc @@ -0,0 +1,13 @@ +import mill._, scalalib._, scalafmt._ + +object commando extends ScalaModule with ScalafmtModule { + override def scalaVersion = "2.13.0" + + object test extends Tests { + def ivyDeps = Agg( + ivy"com.lihaoyi::utest:0.7.1" + ) + def testFrameworks = Seq("utest.runner.Framework") + } + +} 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")) + } + } +} diff --git a/project/build.properties b/project/build.properties deleted file mode 100644 index c0bab04..0000000 --- a/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=1.2.8 diff --git a/project/plugins.sbt b/project/plugins.sbt deleted file mode 100644 index 233cf8d..0000000 --- a/project/plugins.sbt +++ /dev/null @@ -1,6 +0,0 @@ -addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1") - -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "0.6.0") -addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "0.6.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.26") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.3.8") diff --git a/project/publish.sbt b/project/publish.sbt deleted file mode 100644 index b39e15a..0000000 --- a/project/publish.sbt +++ /dev/null @@ -1,2 +0,0 @@ -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.3") -addSbtPlugin("io.crashbox" % "sbt-gpg" % "0.2.0") diff --git a/publish.sbt b/publish.sbt deleted file mode 100644 index 9c0a42e..0000000 --- a/publish.sbt +++ /dev/null @@ -1,29 +0,0 @@ -organization in ThisBuild := "io.crashbox" -licenses in ThisBuild := Seq( - ("BSD-3-Clause", url("https://opensource.org/licenses/BSD-3-Clause"))) -homepage in ThisBuild := Some(url("https://github.com/jodersky/commando")) -publishMavenStyle in ThisBuild := true -publishTo in ThisBuild := Some( - if (isSnapshot.value) - Opts.resolver.sonatypeSnapshots - else - Opts.resolver.sonatypeStaging -) -scmInfo in ThisBuild := Some( - ScmInfo( - url("https://github.com/jodersky/commando"), - "scm:git@github.com:jodersky/commando.git" - ) -) -developers in ThisBuild := List( - Developer( - id = "jodersky", - name = "Jakob Odersky", - email = "jakob@odersky.com", - url = url("https://crashbox.io") - ) -) -version in ThisBuild := { - import sys.process._ - ("git describe --always --dirty=-SNAPSHOT --match v[0-9].*" !!).tail.trim -} diff --git a/src/main/scala/completion/Bash.scala b/src/main/scala/completion/Bash.scala deleted file mode 100644 index b22e054..0000000 --- a/src/main/scala/completion/Bash.scala +++ /dev/null @@ -1,107 +0,0 @@ -package commando -package completion - -object Bash { - - private def addCommands(command: Command): String = { - command.commands.map(c => s"""commands+=("${c.name}")\n""").mkString - } - - private def addFlags(command: Command): String = { - command.optionals.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): String = { - 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 deleted file mode 100644 index 8de9e3a..0000000 --- a/src/main/scala/definitions.scala +++ /dev/null @@ -1,66 +0,0 @@ -package commando - -import commando.completion.Bash - -sealed trait Parameter { - def usage: String -} - -case class Optional(long: String, - short: Option[Char] = None, - argumentAllowed: Boolean = false, - argumentRequired: Boolean = false, - parameterName: String = "param") - extends Parameter { - - def usage: String = { - val shortString = short.map(c => s"-$c|").getOrElse("") - val paramString = if (argumentRequired) { - s"=<$parameterName>" - } else if (argumentAllowed) { - s"[=<$parameterName>]" - } else { - "" - } - s"[$shortString--$long$paramString]" - } -} - -case class Positional(name: String, required: Boolean = true) - extends Parameter { - def usage: String = if (required) s"<$name>" else s"[<$name>]" -} - -case class Command( - name: String, - optionals: Set[Optional], - positionals: Seq[Positional], - commands: Set[Command] = Set.empty, - action: Option[Command.Arguments => Unit] = None -) { - - private def subusage(level: Int): String = { - val optStrings = optionals.map { opt => - opt.usage - } - val posStrings = positionals map { pos => - pos.usage - } - val cmdStrings = Seq(commands.map(cmd => cmd.name).mkString("|")) - - val headline = - (Seq(name) ++ optStrings ++ posStrings ++ cmdStrings).mkString(" ") - val lines = commands - .map(_.subusage(level + 1)) - .map(line => " " * (level + 1) + line) - headline + lines.mkString("\n", "", "") - } - - def usage: String = subusage(0) - - def completion: String = Bash.completion(this) -} - -object Command { - type Arguments = Map[String, Seq[String]] -} diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala deleted file mode 100644 index 68936cd..0000000 --- a/src/main/scala/package.scala +++ /dev/null @@ -1,50 +0,0 @@ -package commando - -class CommandBuilder(name: String, params: Seq[Parameter]) { - - private def optionals = - params.collect { - case opt: Optional => opt - }.toSet - private def positionals = params.collect { - case pos: Positional => pos - } - - def run(action: Command.Arguments => Unit): Command = - Command(name, optionals, positionals, Set.empty, Some(action)) - - def sub(commands: Command*): Command = - Command(name, optionals, positionals, commands.toSet, None) - -} - -object `package` { - - val DefaultErrorHandler: (Command, String) => Unit = - (command: Command, err: String) => { - System.err.println(s"${command.name}: $err") - System.exit(2) - } - - def parse(arguments: Seq[String], - command: Command, - onError: (Command, String) => Unit = DefaultErrorHandler): Unit = - Parser.parse(arguments, command, onError) - - def cmd(name: String)(params: Parameter*): CommandBuilder = - new CommandBuilder(name, params) - def opt(name: String, - short: Char = '\u0000', - param: (String, Boolean) = ("", false)): Optional = - Optional( - name, - if (short == '\u0000') None else Some(short), - argumentAllowed = (param != ("", false)), - argumentRequired = (param != ("", false)) && param._2, - parameterName = if (param._1 == "") "param" else param._1 - ) - - def pos(name: String, required: Boolean = true): Positional = - Positional(name, required) - -} diff --git a/src/main/scala/parsing.scala b/src/main/scala/parsing.scala deleted file mode 100644 index 2526d39..0000000 --- a/src/main/scala/parsing.scala +++ /dev/null @@ -1,212 +0,0 @@ -package commando -import scala.collection.mutable - -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]): Iterator[Token] = new Iterator[Token] { - val args = input.iterator - val shortOptions = new mutable.Queue[Token] - - var escaping = false - override def hasNext: Boolean = args.hasNext || shortOptions.nonEmpty - - override def next(): Token = - if (shortOptions.nonEmpty) { - 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(args: Seq[String], - command: Command, - onError: (Command, String) => Unit): Unit = - 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 - } - - val _parsedArguments = new mutable.HashMap[String, List[String]] - def addArgument(name: String, value: String): Unit = - _parsedArguments.get(name) match { - case None => _parsedArguments(name) = value :: Nil - case Some(values) => _parsedArguments(name) = value :: values - } - def parsedArguments = - _parsedArguments.map { - case (key, values) => - key -> values.reverse.toSeq - }.toMap - - def line(command: Command): Unit = { - val longs: Map[String, Optional] = command.optionals.map { - case opt: Optional => opt.long -> opt - }.toMap - val shorts: Map[String, Optional] = command.optionals.collect { - case opt: Optional if opt.short.isDefined => - opt.short.get.toString -> opt - }.toMap - val remainingPositionals = command.positionals.collect { - case pos: Positional => pos - }.iterator - val subcommands: Map[String, Command] = command.commands.map { cmd => - cmd.name -> cmd - }.toMap - - def fatal(message: String) = throw new ParseException(message) - - def optional(): Unit = { - val tok = accept() - val parts = tok.value.split("=", 2) - val name = parts(0) - val embedded: Option[String] = - if (parts.size > 1) Some(parts(1)) else None - val opt = (tok.kind: @unchecked) match { - case LONG => - longs.getOrElse(name, fatal(s"unknown option '--$name'")) - case SHORT => - shorts.getOrElse(name, fatal(s"unknown option '-$name'")) - } - - if (opt.argumentRequired) { - embedded match { - case Some(value) => - addArgument(opt.long, value) - case None if token.kind == POSITIONAL => - addArgument(opt.long, accept().value) - case None => - fatal( - s"option ${opt.usage} requires an argument but ${token.value} found") - } - } else if (opt.argumentAllowed) { - embedded match { - case Some(value) => - addArgument(opt.long, value) - case None => - if (token.kind == POSITIONAL) { - addArgument(opt.long, accept().value) - } else { - addArgument(opt.long, "") - } - } - } else { // no argument allowed - embedded match { - case Some(value) => - fatal( - s"no argument allowed for option ${opt.usage} (it is set to $value)") - case None => addArgument(opt.long, "") - } - } - } - - def positional(): Unit = { - if (remainingPositionals.hasNext) { - addArgument( - remainingPositionals.next.name, - accept().value - ) - } else { - fatal(s"too many arguments: '${token.value}'") - } - } - - // make sure all required positional parameters have been parsed - def checkPositionals(): Unit = { - val remaining = remainingPositionals.toList - if (!remaining.forall(_.required == false)) { - val missing = remaining.map(p => s"'${p.name}'") - fatal(s"missing parameter(s) ${missing.mkString(", ")}") - } - } - - var escaping = false - @annotation.tailrec - def innerLine(): Unit = { - if (token.kind == EOL) { - checkPositionals() - if (subcommands.nonEmpty) { - val missing = command.commands.map(c => s"'${c.name}'") - fatal( - s"command not specified (must be one of ${missing.mkString(", ")})") - } - command.action.get(parsedArguments) - } else if (escaping) { - positional() - innerLine() - } else if (token.kind == DOUBLE_DASH) { - escaping = true - readToken() - innerLine() - } else if (token.kind == POSITIONAL && remainingPositionals.nonEmpty) { - positional() - innerLine() - } else if (token.kind == POSITIONAL) { - if (subcommands.isEmpty) { - fatal(s"too many arguments: '${token.value}'") - } else { - subcommands.get(token.value) match { - case None => - val cmds = command.commands.map(c => s"'${c.name}'") - fatal( - s"command '${token.value}' not found (must be one of ${cmds - .mkString(", ")})") - case Some(cmd) => - checkPositionals() - readToken() - line(cmd) - } - } - } else if (token.kind == LONG || token.kind == SHORT) { - optional() - innerLine() - } else { - fatal(s"unknown token $token") - } - } - innerLine() - } - readToken() - line(command) - } catch { - case ex: ParseException => onError(command, ex.getMessage) - } - -} diff --git a/src/test/scala/ParserTest.scala b/src/test/scala/ParserTest.scala deleted file mode 100644 index 579ba95..0000000 --- a/src/test/scala/ParserTest.scala +++ /dev/null @@ -1,143 +0,0 @@ -package commando - -import utest._ - -object ParserTest extends TestSuite { - - implicit class EliteCommando(line: String) { - def parse(command: Command): Unit = { - val args = line.split(" ").tail - commando.parse(args, command, (c, err) => throw new ParseException(err)) - } - def fail(command: Command, message: String): Unit = { - val args = line.split(" ").tail - try { - commando.parse(args, command, (c, err) => throw new ParseException(err)) - sys.error("parsing succeeded but was expected to fail") - } catch { - case err: ParseException if err.getMessage.contains(message) => - case err: ParseException => - sys.error(s"parsing failed for the wrong reason: ${err.getMessage}") - } - } - } - - def cbx(asserts: Command.Arguments => Unit) = - cmd("cbx")( - opt("server", 's', "name" -> true) - ).sub( - cmd("version")( - opt("verbose", 'v', "k=v" -> false) - ).run(asserts), - cmd("login")( - pos("server_url"), - pos("username", false), - pos("password", false) - ).run(asserts), - cmd("run")( - opt("file", 'f', "file_name" -> true), - opt("force"), - pos("pipeline", false) - ).run(asserts), - cmd("level1")().sub( - cmd("level2-1")( - pos("p2") - ).sub( - cmd("level3")(pos("p3")).run(asserts) - ), - cmd("level2-2")().run(asserts) - ) - ) - - val tests = Tests { - "print usage" - { - cbx(_ => ()).usage - } - "simple" - { - "cbx version" parse cbx { args => - args ==> Map.empty - } - } - "empty allowed optional" - { - "cbx version -v" parse cbx { args => - args ==> Map("verbose" -> Seq("")) - } - "cbx version --verbose" parse cbx { args => - args ==> Map("verbose" -> Seq("")) - } - } - "set allowed optional" - { - "cbx version -v x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose=x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose=x=y" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y")) - } - "cbx version --verbose=x=y,z=w" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y,z=w")) - } - "cbx version --verbose x=y" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y")) - } - "cbx version --verbose x=y z=w".fail(cbx(_ => ()), "too many arguments") - } - "required argument optional" - { - "cbx run" parse cbx { _ ==> Map.empty } // make sure it works first - "cbx run -f x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file=x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file=x=y,z=w" parse cbx { _ ==> Map("file" -> Seq("x=y,z=w")) } - "cbx run --file".fail(cbx(_ => ()), "requires") - "cbx run --file --".fail(cbx(_ => ()), "requires") - } - "no argument optional" - { - "cbx run --force=x".fail(cbx(_ => ()), "no argument allowed") - "cbx run --force x" parse cbx { _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("x")) } - } - "global optional" - { - "cbx --server run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx -s run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx --server=run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx -x run".fail(cbx(_ => ()), "unknown option") - "cbx --x run".fail(cbx(_ => ()), "unknown option") - } - "positional" - { - "cbx login x" parse cbx { _ ==> Map("server_url" -> Seq("x"))} - "cbx login x y" parse cbx { _ ==> Map("server_url" -> Seq("x"), "username" -> Seq("y"))} - "cbx login x y z" parse cbx { _ ==> Map("server_url" -> Seq("x"), "username" -> Seq("y"), "password" -> Seq("z"))} - "cbx login - x y z".fail(cbx(_ => ()), "too many") - "cbx login - y" parse cbx { _ ==> Map("server_url" -> Seq("-"), "username" -> Seq("y"))} - } - "out of order options" - { - "cbx run --force pipelinename -f x" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("pipelinename"), "file" -> Seq("x")) - } - "cbx run --force -- -f" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("-f")) - } - "cbx run --force -- --file" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("--file")) - } - "cbx run --force -- --" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("--")) - } - "cbx run --force -- -f x".fail(cbx(_ => ()), "too many") - } - "nested1" - { - "cbx level1 level2-1 x=y level3 z" parse cbx { - _ ==> Map("p2" -> Seq("x=y"), "p3" -> Seq("z")) - } - } - "nested2" - { - "cbx level1 level2-2 --" parse cbx { - _ ==> Map.empty - } - } - } -} -- cgit v1.2.3