aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-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
4 files changed, 397 insertions, 0 deletions
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)
+ }
+
+}