aboutsummaryrefslogblamecommitdiff
path: root/src/main/scala/parsing.scala
blob: 31880faf63013e66685316f43dfd0f1ecb221b89 (plain) (tree)
1
                







































































                                                                       
                                                                

                                     
                                                                     








































































                                                                                 
                                                                                  



















































                                                                                  
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)
    }

}