aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2018-04-29 17:59:47 -0700
committerJakob Odersky <jakob@odersky.com>2018-04-29 17:59:47 -0700
commiteef44a276ace54ef0b215c1c4b045afde40daf5b (patch)
tree83bc5aa811492ee8cb8ad6ec05258904d9a3d49d
parent4562b1b5754f4b840016be345eb6ee92f5abf258 (diff)
downloadcommando-eef44a276ace54ef0b215c1c4b045afde40daf5b.tar.gz
commando-eef44a276ace54ef0b215c1c4b045afde40daf5b.tar.bz2
commando-eef44a276ace54ef0b215c1c4b045afde40daf5b.zip
Specify action inline with command
-rw-r--r--src/main/scala/completion/Bash.scala10
-rw-r--r--src/main/scala/definitions.scala87
-rw-r--r--src/main/scala/package.scala52
-rw-r--r--src/main/scala/parsing.scala145
-rw-r--r--src/test/scala/CmdTest.scala.disabled (renamed from src/test/scala/CmdTest.scala)6
-rw-r--r--src/test/scala/ParserTest.scala27
6 files changed, 196 insertions, 131 deletions
diff --git a/src/main/scala/completion/Bash.scala b/src/main/scala/completion/Bash.scala
index e049fe1..db7b40a 100644
--- a/src/main/scala/completion/Bash.scala
+++ b/src/main/scala/completion/Bash.scala
@@ -8,9 +8,9 @@ object Bash {
}
private def addFlags(command: Command): String = {
- command.options.map { opt =>
+ command.optionals.map { opt =>
val extra = if (opt.argumentRequired) "=" else ""
- val short = opt.short.map(c => s"""flags+=("-${c}")""").getOrElse("")
+ val short = opt.short.map(c => s"""flags+=("-$c")""").getOrElse("")
s"""|flags+=("--${opt.long}$extra")
|$short
|""".stripMargin
@@ -37,7 +37,7 @@ object Bash {
}
}
- def completion(command: Command) = {
+ def completion(command: Command): String = {
val name = command.name
s"""__${name}_contains_word() {
@@ -89,7 +89,7 @@ object Bash {
| local c=0
| COMPREPLY=()
|
- | local last_command="_${name}"
+ | local last_command="_$name"
| local commands=()
| local flags=()
|
@@ -100,7 +100,7 @@ object Bash {
|
| return 0
|}
- |complete -o default -F __${name}_start ${name}
+ |complete -o default -F __${name}_start $name
|""".stripMargin
}
diff --git a/src/main/scala/definitions.scala b/src/main/scala/definitions.scala
index b009e41..15c8e2c 100644
--- a/src/main/scala/definitions.scala
+++ b/src/main/scala/definitions.scala
@@ -1,71 +1,66 @@
package commando
-sealed trait Definition
+import commando.completion.Bash
+
+sealed trait Parameter {
+ def usage: String
+}
case class Optional(long: String,
short: Option[Char] = None,
- parameter: Option[Positional] = None)
- extends Definition {
- def argumentAllowed: Boolean = parameter.isDefined
- def argumentRequired: Boolean = parameter.map(_.required).getOrElse(false)
- override def toString = {
+ argumentAllowed: Boolean = false,
+ argumentRequired: Boolean = false,
+ parameterName: String = "param")
+ extends Parameter {
+
+ def usage: String = {
val shortString = short.map(c => s"-$c|").getOrElse("")
- val argString = parameter match {
- case None => ""
- case Some(Positional(argName, false)) => s"[=<$argName>]"
- case Some(Positional(argName, true)) => s"=<$argName>"
+ val paramString = if (argumentRequired) {
+ s"=<$parameterName>"
+ } else if (argumentAllowed) {
+ s"[=<$parameterName>]"
+ } else {
+ ""
}
- s"[$shortString--$long$argString]"
+ s"[$shortString--$long$paramString]"
}
}
-case class Positional(
- name: String,
- required: Boolean = true
-) extends Definition {
- override def toString = if (required) s"<$name>" else s"[<$name>]"
+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,
- options: Set[Optional] = Set.empty,
- parameters: Seq[Positional] = Seq.empty,
- commands: Set[Command] = Set.empty
-) extends Definition {
- override def toString = name
+ name: String,
+ optionals: Set[Optional],
+ positionals: Seq[Positional],
+ commands: Set[Command] = Set.empty,
+ action: Option[Command.Arguments => Unit] = None
+) {
- def subusage(level: Int): String = {
- val optionStrings = options.map { opt =>
- opt.toString
+ private def subusage(level: Int): String = {
+ val optStrings = optionals.map { opt =>
+ opt.usage
}
- val parameterStrings = parameters map { param =>
- param.toString
+ val posStrings = positionals map { pos =>
+ pos.usage
}
- val commandStrings = Seq(commands.map(cmd => cmd.name).mkString("|"))
+ val cmdStrings = Seq(commands.map(cmd => cmd.name).mkString("|"))
val headline =
- (Seq(name) ++ optionStrings ++ parameterStrings ++ commandStrings)
- .mkString(" ")
- val sublines = commands
+ (Seq(name) ++ optStrings ++ posStrings ++ cmdStrings).mkString(" ")
+ val lines = commands
.map(_.subusage(level + 1))
.map(line => " " * (level + 1) + line)
- headline + sublines.mkString("\n", "", "")
+ headline + lines.mkString("\n", "", "")
}
- def usage: String = "Usage: " + subusage(0)
-
- def completion: String = commando.completion.Bash.completion(this)
+ def usage: String = subusage(0)
+ def completion: String = Bash.completion(this)
}
-object Command {
-
- def apply(name: String, defs: Definition*): Command = {
- Command(
- name,
- options = defs.collect { case opt: Optional => opt }.toSet,
- parameters = defs.collect { case param: Positional => param }.toSeq,
- commands = defs.collect { case cmd: Command => cmd }.toSet
- )
- }
-}
+object Command {
+ type Arguments = Map[String, Seq[String]]
+} \ No newline at end of file
diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala
index b35740c..f813971 100644
--- a/src/main/scala/package.scala
+++ b/src/main/scala/package.scala
@@ -1,14 +1,48 @@
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` {
- 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)
+
+ 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
index 31880fa..95edc05 100644
--- a/src/main/scala/parsing.scala
+++ b/src/main/scala/parsing.scala
@@ -1,34 +1,27 @@
package commando
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 SHORT extends TokenKind // -<n>
+ private case object LONG extends TokenKind // --<n>
+ private case object POSITIONAL extends TokenKind // <n>
+ 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] {
+ private def lex(input: Seq[String]): Iterator[Token] = new Iterator[Token] {
val args = input.iterator
val shortOptions = new mutable.Queue[Token]
var escaping = false
- def hasNext = args.hasNext || !shortOptions.isEmpty
+ override def hasNext: Boolean = args.hasNext || shortOptions.nonEmpty
- def next(): Token =
- if (!shortOptions.isEmpty) {
+ override def next(): Token =
+ if (shortOptions.nonEmpty) {
shortOptions.dequeue()
} else {
val arg = args.next
@@ -51,8 +44,9 @@ object Parser {
}
}
- def parse(command: Command,
- args: Seq[String]): Either[ParseException, CommandLine] =
+ 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)
@@ -70,25 +64,40 @@ object Parser {
tok
}
- def line(command: Command): CommandLine = {
- val longs: Map[String, Optional] = command.options.map {
- case opt => opt.long -> opt
+ 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.options.collect {
- case opt if opt.short.isDefined => opt.short.get.toString -> opt
+ val shorts: Map[String, Optional] = command.optionals.collect {
+ case opt: Optional if opt.short.isDefined =>
+ opt.short.get.toString -> opt
}.toMap
- val subcommands: Map[String, Command] = command.commands.map {
- case cmd => cmd.name -> cmd
+ 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(s"${command.name}: $message")
+ def fatal(message: String) = throw new ParseException(message)
- def option(): (String, String) = {
+ def optional(): Unit = {
val tok = accept()
val parts = tok.value.split("=", 2)
val name = parts(0)
- val embedded: Maybe[String] =
+ val embedded: Option[String] =
if (parts.size > 1) Some(parts(1)) else None
val opt = (tok.kind: @unchecked) match {
case LONG =>
@@ -99,76 +108,75 @@ object Parser {
if (opt.argumentRequired) {
embedded match {
- case Some(value) => opt.long -> value
+ case Some(value) =>
+ addArgument(opt.long, value)
case None if token.kind == POSITIONAL =>
- opt.long -> accept().value
+ addArgument(opt.long, accept().value)
case None =>
fatal(
- s"option ${opt} requires an argument but ${token.value} found")
+ s"option ${opt.usage} requires an argument but ${token.value} found")
}
} else if (opt.argumentAllowed) {
embedded match {
- case Some(value) => opt.long -> value
+ case Some(value) =>
+ addArgument(opt.long, value)
case None =>
if (token.kind == POSITIONAL) {
- opt.long -> accept.value
+ addArgument(opt.long, accept().value)
} else {
- opt.long -> ""
+ addArgument(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 -> ""
+ s"no argument allowed for option ${opt.usage} (it is set to $value)")
+ case None => addArgument(opt.long, "")
}
}
}
- val remainingParameters = command.parameters.iterator
- def parameter(): (String, String) = {
- if (remainingParameters.hasNext) {
- remainingParameters.next.name -> accept().value
+ def positional(): Unit = {
+ if (remainingPositionals.hasNext) {
+ addArgument(
+ remainingPositionals.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
+ // make sure all required positional parameters have been parsed
+ def checkPositionals(): Unit = {
+ val remaining = remainingPositionals.toList
if (!remaining.forall(_.required == false)) {
- val missing = remaining.toList.map(p => s"'${p.name}'")
+ val missing = remaining.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"command not specified (must be one of ${missing.mkString(", ")})")
- } else {
- CommandLine(command.name,
- parsedOptions.toMap ++ parsedParameters.toMap,
- subline)
}
}
+ var escaping = false
@annotation.tailrec
- def innerLine(): CommandLine = {
+ def innerLine(): Unit = {
if (token.kind == EOL) {
- check(None)
+ 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) {
- parsedParameters += parameter()
+ positional()
innerLine()
} else if (token.kind == DOUBLE_DASH) {
escaping = true
readToken()
innerLine()
- } else if (token.kind == POSITIONAL && !remainingParameters.isEmpty) {
- parsedParameters += parameter()
+ } else if (token.kind == POSITIONAL && remainingPositionals.nonEmpty) {
+ positional()
innerLine()
} else if (token.kind == POSITIONAL) {
if (subcommands.isEmpty) {
@@ -178,15 +186,16 @@ object Parser {
case None =>
val cmds = command.commands.map(c => s"'${c.name}'")
fatal(
- s"subcommand '${token.value}' not found (must be one of ${cmds
+ s"command '${token.value}' not found (must be one of ${cmds
.mkString(", ")})")
- case Some(_) =>
- val subline = line(subcommands(accept().value))
- check(Some(subline))
+ case Some(cmd) =>
+ checkPositionals()
+ readToken()
+ line(cmd)
}
}
} else if (token.kind == LONG || token.kind == SHORT) {
- parsedOptions += option()
+ optional()
innerLine()
} else {
fatal(s"unknown token $token")
@@ -195,9 +204,9 @@ object Parser {
innerLine()
}
readToken()
- Right(line(command))
+ line(command)
} catch {
- case ex: ParseException => Left(ex)
+ case ex: ParseException => onError(command, ex.getMessage)
}
}
diff --git a/src/test/scala/CmdTest.scala b/src/test/scala/CmdTest.scala.disabled
index f78611d..d943a79 100644
--- a/src/test/scala/CmdTest.scala
+++ b/src/test/scala/CmdTest.scala.disabled
@@ -6,16 +6,16 @@ object CmdTests extends TestSuite {
val cbx = commando.Command(
"cbx",
- commando.Optional("server", Some('s'), Some(commando.Positional("name"))),
+ commando.Optional("server", Some('s'), Optional.ArgRequired("name")),
commando.Command(
"version",
- commando.Optional("verbose", Some('v'), Some(commando.Positional("k=v", false)))),
+ commando.Optional("verbose", Some('v'), Optional.ArgAllowed("k=v"))),
commando.Command("login",
commando.Positional("server_url"),
commando.Positional("username", false),
commando.Positional("password", false)),
commando.Command("run",
- commando.Optional("file", Some('f'), Some(commando.Positional("file_name"))),
+ commando.Optional("file", Some('f'), Optional.ArgRequired("file_name")),
commando.Optional("force", None),
commando.Positional("pipeline", false)),
commando.Command("level1",
diff --git a/src/test/scala/ParserTest.scala b/src/test/scala/ParserTest.scala
new file mode 100644
index 0000000..11694c4
--- /dev/null
+++ b/src/test/scala/ParserTest.scala
@@ -0,0 +1,27 @@
+package commando
+
+import utest._
+
+object ParserTest extends TestSuite {
+
+ implicit class EliteCommando(line: String) {
+ def parse(command: Command): Unit = {
+ val args = line.split(" ")
+ commando.parse(args, command)(err => throw new ParseException(err))
+ }
+ }
+
+ val tests = Tests {
+ "foo" - {
+ val command = cmd("cbx")(
+ opt("server", 'S', param = "url" -> false),
+ pos("number")
+ ).run(
+ ctx => println("yoyo, my context was: " + ctx)
+ )
+ "--server x 3 -S 5 --server=2 --server 2".parse(command)
+ println(command.usage)
+ }
+ }
+
+}