diff options
Diffstat (limited to 'src/main')
-rw-r--r-- | src/main/scala/completion/Bash.scala | 107 | ||||
-rw-r--r-- | src/main/scala/definitions.scala | 73 | ||||
-rw-r--r-- | src/main/scala/package.scala | 14 | ||||
-rw-r--r-- | src/main/scala/parsing.scala | 203 |
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) + } + +} |