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 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, Optional] = command.options.map { case opt => opt.long -> opt }.toMap val shorts: Map[String, Optional] = 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"command not specified (must be 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) } }