All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.rogach.scallop.Scallop.scala Maven / Gradle / Ivy

There is a newer version: 5.1.0
Show newest version
package org.rogach.scallop

import org.rogach.scallop.exceptions._

import scala.collection.{Seq => CSeq}

private[scallop] object Scallop {

  /** Create the new parser with some arguments already inserted.
    *
    * @param args Args to pre-insert.
    */
  def apply(args: CSeq[String]): Scallop = new Scallop(args)

  /** Create the default empty parser, fresh as mountain air. */
  def apply(): Scallop = apply(Nil)

  private[scallop] def builtinHelpOpt =
    SimpleOption(
      name = "help",
      short = None,
      descr = "Show help message",
      required = false,
      converter = flagConverter,
      default = () => None,
      validator = (_) => true,
      argName = "",
      hidden = false,
      noshort = true
    )

  private[scallop] def builtinVersionOpt =
    SimpleOption(
      name = "version",
      short = None,
      descr = "Show version of this program",
      required = false,
      converter = flagConverter,
      default = () => None,
      validator = (_) => true,
      argName = "",
      hidden = false,
      noshort = true
    )
}

/** Internal configuration builder. */
case class Scallop(
  args: CSeq[String] = Nil,
  opts: List[CliOption] = Nil,
  mainOptions: List[CliOption] = Nil,
  optionGroups: List[(String, Seq[CliOption])] = Nil,
  vers: Option[String] = None,
  bann: Option[String] = None,
  foot: Option[String] = None,
  descr: String = "",
  helpWidth: Option[Int] = None,
  shortSubcommandsHelp: Boolean = false,
  appendDefaultToDescription: Boolean = false,
  noshort: Boolean = false,
  helpFormatter: ScallopHelpFormatter = new ScallopHelpFormatter,
  subbuilders: List[(String, Scallop)] = Nil
) extends ScallopArgListLoader {

  var parent: Option[Scallop] = None

  case class CliOptionInvocation(
    opt: CliOption,
    invocation: String,
    args: List[String],
    error: Option[ScallopException] = None
  )

  type Parsed = List[CliOptionInvocation]

  case class ParseResult(
    opts: Parsed = Nil,
    subcommand: Option[String] = None,
    subcommandArgs: List[String] = Nil
  )

  /** Parse the argument into list of options and their arguments. */
  private def parse(args: CSeq[String]): ParseResult = {
    subbuilders.filter(s => args.contains(s._1)).sortBy(s => args.indexOf(s._1)).headOption match {
      case Some((name, sub)) =>
        ParseResult(
          parse(Nil, args.takeWhile(name != _).toList, Nil),
          Some(name),
          args.dropWhile(name != _).drop(1).toList
        )
      case None =>
        ParseResult(parse(Nil, args.toList, Nil))
    }
  }
  @annotation.tailrec
  private def parse(
    acc: Parsed,
    args: List[String],
    leadingArgsAcc: List[String]
  ): Parsed = {

    def goParseRest(
      leadingArgs: List[String],
      lastMultiArgOption: Option[(CliOption, String)],
      trailingArgs: List[String]
    ): Parsed = {
      def parseRest(): Parsed = {
        val trailingOptions = opts.filter(_.isPositional)

        (lastMultiArgOption, trailingOptions) match {
          case (None, singleTrailingOption :: Nil) if singleTrailingOption.converter.argType == ArgType.LIST =>
            List(CliOptionInvocation(
              opt = singleTrailingOption,
              invocation = "",
              args = leadingArgs ++ trailingArgs
            ))
          case (Some((singleOption, invocation)), Nil) if leadingArgs.isEmpty && singleOption.converter.argType == ArgType.LIST =>
            List(CliOptionInvocation(
              opt = singleOption,
              invocation = invocation,
              args = trailingArgs
            ))
          case _ =>
            val result = TrailingArgumentsParser.parse(
              leadingArgs,
              lastMultiArgOption,
              trailingArgs,
              trailingOptions
            )
            result match {
              case TrailingArgumentsParser.ParseResult(_, _, excess) if excess.nonEmpty =>
                throw ExcessArguments(excess)

              case TrailingArgumentsParser.ParseResult(result, _, _) =>
                result.flatMap {
                  case (option, invocation, Right(args)) =>
                    if (args.nonEmpty || option.required) {
                      List(CliOptionInvocation(
                        opt = option,
                        invocation = invocation,
                        args = args
                      ))
                    } else {
                      Nil
                    }
                  case (option, invocation, Left((message, args))) =>
                    if (option.required && (message == "not enough arguments")) {
                      List(CliOptionInvocation(
                        opt = option,
                        invocation = invocation,
                        args = args,
                        error = Some(RequiredOptionNotFound(option.name))
                      ))
                    } else {
                      List(CliOptionInvocation(
                        opt = option,
                        invocation = invocation,
                        args = args,
                        error = Some(WrongOptionFormat(option.name, args.mkString(" "), message))
                      ))
                    }
                }
            }
        }
      }

      lastMultiArgOption match {
        case Some((option, invocation)) =>
          option.converter.argType match {
            // handle simple option types immediately to avoid going into trailing args parsing with extra options
            case ArgType.FLAG =>
              CliOptionInvocation(option, invocation, Nil) :: goParseRest(leadingArgs, None, trailingArgs)
            case ArgType.SINGLE =>
              if (trailingArgs.size > 0) {
                CliOptionInvocation(option, invocation, trailingArgs.take(1).toList) :: goParseRest(leadingArgs, None, trailingArgs.tail)
              } else {
                List(CliOptionInvocation(
                  opt = option,
                  invocation = invocation,
                  args = trailingArgs,
                  error = Some(WrongOptionFormat(option.name, trailingArgs.mkString, "you should provide exactly one argument"))
                ))
              }
            // short-circuit parsing when there are no trailing args - to get better error messages
            case ArgType.LIST if trailingArgs.isEmpty =>
              List(CliOptionInvocation(
                opt = option,
                invocation = invocation,
                args = Nil
              ))
            case ArgType.LIST => parseRest()
          }
        case None => parseRest()
      }
    }

    if (args.isEmpty) {
      if (leadingArgsAcc.isEmpty) {
        acc.reverse
      } else {
        // only trailing args left - proceed to trailing args parsing
        acc.reverse ::: goParseRest(Nil, None, leadingArgsAcc.reverse ::: removeFirstTrailingArgsSeparator(args))
      }
    } else if (args.head == "--") {
      // separator overrides any options that may follow, all remaining arguments go into trailing arguments
      acc.reverse ::: goParseRest(leadingArgsAcc.reverse, None, args.tail)
    } else if (isOptionName(args.head)) {
      if (args.head.startsWith("--")) {
        opts.find(_.longNames.exists(name => args.head.startsWith("--" + name + "="))) match {

          // parse --arg=value option style
          case Some(opt) =>
            val (invocation, arg) = args.head.drop(2).span('=' != _)
            parse(
              acc = CliOptionInvocation(opt, invocation, List(arg.drop(1))) :: acc,
              args = args.tail,
              leadingArgsAcc = leadingArgsAcc
            )

          // parse --arg value... option style
          case None =>
            val invocation = args.head.drop(2)
            val option =
              opts.find(_.longNames.contains(invocation))
              .orElse(if (invocation == "help") Some(getHelpOption) else None)
              .orElse(if (invocation == "version") getVersionOption else None)
              .getOrElse(NonexistentOption)
            val (matchedArgs, remainingArgs) =
              option.converter.argType match {
                case ArgType.FLAG => (Nil, args.tail)
                case ArgType.SINGLE => (args.tail.take(1), args.tail.drop(1))
                case ArgType.LIST => args.tail.span(isArgument)
              }

            if (option == NonexistentOption) {
              val error = Some(UnknownOption(invocation))
              if (remainingArgs.isEmpty) {
                (CliOptionInvocation(option, invocation, matchedArgs.toList, error) :: acc).reverse
              } else {
                parse(
                  acc = CliOptionInvocation(option, invocation, matchedArgs.toList, error) :: acc,
                  args = remainingArgs,
                  leadingArgsAcc = leadingArgsAcc
                )
              }
            } else if (remainingArgs.isEmpty) {
              // proceed to trailing args parsing
              acc.reverse ::: goParseRest(leadingArgsAcc.reverse, Some((option, invocation)), args.tail)
            } else {
              parse(
                acc = CliOptionInvocation(option, invocation, matchedArgs.toList) :: acc,
                args = remainingArgs,
                leadingArgsAcc = leadingArgsAcc
              )
            }
        }
      } else {
        if (args.head.size == 2) {
          val invocation = args.head.drop(1)
          val option = getOptionWithShortName(args.head(1)).getOrElse(NonexistentOption)
          val (matchedArgs, remainingArgs) =
            option.converter.argType match {
              case ArgType.FLAG => (Nil, args.tail)
              case ArgType.SINGLE => (args.tail.take(1), args.tail.drop(1))
              case ArgType.LIST => args.tail.span(isArgument)
            }

          if (option == NonexistentOption) {
            val error = Some(UnknownOption(invocation))
            if (remainingArgs.isEmpty) {
              (CliOptionInvocation(option, invocation, matchedArgs.toList, error) :: acc).reverse
            } else {
              parse(
                acc = CliOptionInvocation(option, invocation, matchedArgs.toList, error) :: acc,
                args = remainingArgs,
                leadingArgsAcc = leadingArgsAcc
              )
            }
          } else if (remainingArgs.isEmpty) {
            // proceed to trailing args parsing
            acc.reverse ::: goParseRest(leadingArgsAcc.reverse, Some((option, invocation)), args.tail)
          } else {
            parse(
              acc = CliOptionInvocation(option, invocation, matchedArgs.toList) :: acc,
              args = remainingArgs,
              leadingArgsAcc = leadingArgsAcc
            )
          }
        } else {
          val option = getOptionWithShortName(args.head(1)).getOrElse(NonexistentOption)
          if (option.converter.argType != ArgType.FLAG) {
            parse(
              acc = acc,
              args = args.head.take(2) :: args.head.drop(2) :: args.tail,
              leadingArgsAcc = leadingArgsAcc
            )
          } else {
            parse(
              acc = acc,
              args = args.head.take(2) :: ("-" + args.head.drop(2)) :: args.tail,
              leadingArgsAcc = leadingArgsAcc
            )
          }
        }
      }
    } else if (args.head.matches("-[0-9]+")) {
      // parse number-only options
      val alreadyMatchedNumbers = acc.count(_.opt.isInstanceOf[NumberArgOption])
      opts.filter(_.isInstanceOf[NumberArgOption]).drop(alreadyMatchedNumbers).headOption match {
        case Some(opt) =>
          val num = args.head.drop(1)
          parse(
            acc = CliOptionInvocation(opt, num, List(num)) :: acc,
            args = args.tail,
            leadingArgsAcc = leadingArgsAcc
          )
        case None =>
          parse(
            acc,
            args = args.tail,
            leadingArgsAcc = args.head :: leadingArgsAcc
          )
      }
    } else {
      // args.head is not an option, so it is a "leading trailing argument":
      // trailing argument that may be followed by some options
      parse(
        acc,
        args = args.tail,
        leadingArgsAcc = args.head :: leadingArgsAcc
      )
    }
  }

  /** Find an option, that responds to this short name. */
  def getOptionWithShortName(c: Char): Option[CliOption] = {
    opts
    .find(_.requiredShortNames.contains(c))
    .orElse {
      opts.find(_.shortNames.contains(c))
    }
    .orElse(Option(getHelpOption).find(_.requiredShortNames.contains(c)))
    .orElse(getVersionOption.find(_.requiredShortNames.contains(c)))
  }

  def getOptionShortNames(opt: CliOption): List[Char] = {
    (opt.shortNames ++ opt.requiredShortNames).distinct
    .filter(sh => getOptionWithShortName(sh).get == opt)
  }

  /** Result of parsing */
  private lazy val parsed: ParseResult = parse(loadArgList(args))

  /** Tests whether this string contains option name, not some number. */
  private def isOptionName(s: String) =
    if (s.startsWith("-"))
      if (s.size > 1)
        !s(1).isDigit
      else if (s.size == 1)
        false
      else true
    else false

  /** Tests whether this string contains option parameter, not option call. */
  private def isArgument(s: String) = !isOptionName(s)

  private def removeFirstTrailingArgsSeparator(args: List[String]): List[String] = {
    val (argsBeforeSeparator, argsAfterSeparator) = args.span("--" != _)
    argsBeforeSeparator ::: argsAfterSeparator.drop(1)
  }

  def appendOption(option: CliOption): Scallop = {
    this.copy(opts = opts :+ option)
  }

  /** Adds a subbuilder (subcommand) to this builder.
    * @param name All arguments after this string would be routed to this builder.
    */
  def addSubBuilder(nameAndAliases: Seq[String], builder: Scallop) = {
    builder.parent = Some(this)
    this.copy(subbuilders = subbuilders ++ nameAndAliases.map(name => name -> builder))
  }

  /** Traverses the tree of subbuilders, using the provided name.
    * @param name Names of subcommand names, that lead to the needed builder, separated by \\0.
    */
  def findSubbuilder(name: String): Option[Scallop] = {
    if (name.contains('\u0000')) {
      val (firstSub, rest) = name.span('\u0000' != _)
      subbuilders.find(_._1 == firstSub).flatMap(_._2.findSubbuilder(rest.tail))
    } else subbuilders.find(_._1 == name).map(_._2)
  }

  /** Retrieves name of the subcommand that was found in input arguments. */
  def getSubcommandName = parsed.subcommand

  /** Retrieves the subbuilder object,
    * that matches the name of the subcommand found in input arguments. */
  def getSubbuilder: Option[Scallop] = parsed.subcommand.flatMap { sn =>
    subbuilders.find(_._1 == sn).map(_._2)
  }

  /** Returns the subcommand arguments. */
  def getSubcommandArgs: List[String] = parsed.subcommandArgs

  /** Returns the list of subcommand names, recursively. */
  def getSubcommandNames: List[String] = {
    parsed.subcommand.map(subName => subbuilders.find(_._1 == subName).map(s => s._1 :: s._2.args(parsed.subcommandArgs).getSubcommandNames).getOrElse(Nil)).getOrElse(Nil)
  }

  /** Retrieves a list of all supplied options (including options from subbuilders). */
  def getAllSuppliedOptionNames: List[String] = {
    opts.map(_.name).filter(isSupplied) ::: parsed.subcommand.map(subName => subbuilders.find(_._1 == subName).map(s => s._2.args(parsed.subcommandArgs)).get.getAllSuppliedOptionNames.map(subName + "\u0000" + _)).getOrElse(Nil)
  }

  /** Add version string to this builder.
    *
    * @param v Version string, to be printed before all other things in help.
    */
  def version(v: String) = this.copy(vers = Some(v))

  /** Add banner string to this builder. Banner should describe your program and provide a short
    * summary on it's usage.
    *
    * @param b Banner string, can contain multiple lines. Note this is not formatted to 80 characters!
    */
  def banner(b: String) = this.copy(bann = Some(b))

  /** Add footer string to this builder. Footer will be printed in help after option definitions.
    *
    * @param f Footer string, can contain multiple lines. Note this is not formatted to 80 characters!
    */
  def footer(f: String) = this.copy(foot = Some(f))

  /** Explicitly sets the needed width for the help printout. */
  def setHelpWidth(w: Int) = this.copy(helpWidth = Some(w))

  /** Get help on options from this builder. The resulting help is carefully formatted to required number of columns (default = 80, change with .setHelpWidth method),
    * and contains info on properties, options and trailing arguments.
    */
  def help: String = helpFormatter.formatHelp(this, "")

  /** Get full help text (with version, banner, option usage and footer) */
  def getFullHelpString(): String = {
    Seq(vers, bann, Some(help), foot).flatten.mkString("\n")
  }

  /** Print help message (with version, banner, option usage and footer) to stdout. */
  def printHelp() = {
    println(getFullHelpString())
  }

  /** Add some more arguments to this builder. They are appended to the end of the original list.
    *
    * @param a arg list to add
    */
  def args(a: Seq[String]): Scallop = this.copy(args = args ++ a)

  /** Tests if this option or trailing arg was explicitly provided by argument list (not from default).
    *
    * @param name Identifier of option or trailing arg definition
    */
  def isSupplied(name: String): Boolean = {
    if (name.contains('\u0000')) {
      // delegating to subbuilder
      parsed.subcommand.map { subc =>
        subbuilders
        .find(_._1 == subc).map(_._2)
        .filter { subBuilder =>
          subbuilders.filter(_._2 == subBuilder)
          .exists(_._1 == name.takeWhile('\u0000' != _))
        }
        .map { subBuilder =>
          subBuilder.args(parsed.subcommandArgs).isSupplied(name.dropWhile('\u0000' != _).drop(1))
        }.getOrElse(false) // only current subcommand can have supplied arguments
      }.getOrElse(false) // no subcommands, so their options are definitely not supplied
    } else {
      opts find (_.name == name) map { opt =>
        val args = parsed.opts.filter(_.opt == opt).map(i => (i.invocation, i.args))
        opt.converter.parseCached(args) match {
          case Right(Some(_)) => true
          case _ => false
        }
      } getOrElse(throw new UnknownOption(name))
    }
  }

   /** Get the value of option (or trailing arg) as Option.
     * @param name Name for option.
     */
  def get(name: String): Option[Any] = {
    if (name.contains('\u0000')) {
      // delegating to subbuilder
      subbuilders.find(_._1 == name.takeWhile('\u0000' != _)).map(_._2.args(parsed.subcommandArgs).get(name.dropWhile('\u0000' != _).drop(1)))
        .getOrElse(throw new UnknownOption(name.replace("\u0000",".")))
    } else {
      opts.find(_.name == name).map { opt =>
        val args = parsed.opts.filter(_.opt == opt).map(i => (i.invocation, i.args))
        opt.converter.parseCached(args) match {
          case Right(parseResult) =>
            parseResult.orElse(opt.default())
          case _ => if (opt.required) throw new MajorInternalException else None
        }
      }.getOrElse(throw new UnknownOption(name))
    }
  }

  def get(name: Char): Option[Any] = get(name.toString)

  /** Get the value of option. If option is not found, this will throw an exception.
    *
    * @param name Name for option.
    */
  def apply(name: String): Any = get(name).get

  def apply(name: Char): Any = apply(name.toString)

  def prop(name: Char, key: String): Option[Any] = apply(name).asInstanceOf[Map[String, Any]].get(key)

  lazy val getHelpOption =
    opts.find(_.name == "help")
    .getOrElse(
      if (opts.exists(opt => getOptionShortNames(opt).contains('h'))) {
        Scallop.builtinHelpOpt
      } else {
        Scallop.builtinHelpOpt.copy(short = Some('h'), noshort = false)
      }
    )

  lazy val getVersionOption =
    vers.map(_ => opts.find(_.name == "version")
    .getOrElse(
      if (opts.exists(opt => getOptionShortNames(opt).contains('v'))) {
        Scallop.builtinVersionOpt
      } else {
        Scallop.builtinVersionOpt.copy(short = Some('v'), noshort = false)
      }
    ))

  /** Verify the builder. Parses arguments, makes sure no definitions clash, no garbage or unknown options are present,
    * and all present arguments are in proper format. It is recommended to call this method before using the results.
    *
    * If there is "--help" or "--version" option present, it prints help or version statement and exits.
    */
  def verify: Scallop = {
    // option identifiers must not clash
    opts map (_.name) groupBy (a=>a) filter (_._2.size > 1) foreach
      (a => throw new IdenticalOptionNames(Util.format("Option identifier '%s' is not unique", a._1)))
    // long options names must not clash
    opts flatMap (_.longNames) groupBy (a=>a) filter (_._2.size > 1) foreach
      (a => throw new IdenticalOptionNames(Util.format("Long option name '%s' is not unique", a._1)))
    // short options names must not clash
    opts flatMap (o => (o.requiredShortNames).distinct) groupBy (a=>a) filter (_._2.size > 1) foreach
      (a => throw new IdenticalOptionNames(Util.format("Short option name '%s' is not unique", a._1)))

    // trigger actual parsing
    parsed

    if (parsed.opts.exists(_.opt == getHelpOption)) {
      throw Help("")
    }

    parsed.opts.foreach { invocation =>
      invocation.error.foreach { exception =>
        throw exception
      }
    }

    getVersionOption.foreach { versionOpt =>
      if (parsed.opts.headOption.exists(_.opt == versionOpt)) {
        throw Version
      }
    }

    // verify subcommand parsing
    parsed.subcommand.map { sn =>
      subbuilders.find(_._1 == sn).map { case (sn, sub)=>
        try {
          sub.args(parsed.subcommandArgs).verify
        } catch {
          case Help("") => throw Help(sn)
          case h @ Help(subname) => throw Help(sn + "\u0000" + subname)
        }
      }
    }

    opts foreach { o =>
      val args = parsed.opts.filter(_.opt == o).map(i => (i.invocation, i.args))
      val res = o.converter.parseCached(args)
      res match {
        case Left(msg) =>
          throw new WrongOptionFormat(o.name, args.map(_._2.mkString(" ")).mkString(" "), msg)
        case _ =>
      }
      if (o.required && !res.fold(_ => false, _.isDefined) && !o.default().isDefined)
        throw new RequiredOptionNotFound(o.name)
      // validaiton
      if (!(get(o.name) map (v => o.validator(v)) getOrElse true))
        throw new ValidationFailure(Util.format("Validation failure for '%s' option parameters: %s", o.name, args.map(_._2.mkString(" ")).mkString(" ")))

    }

    this
  }

  /** Get summary of current parser state.
    *
    * Returns a list of all options in the builder, and corresponding values for them.
    */
  def summary: String = {
    Util.format("Scallop(%s)", args.mkString(", ")) + "\n" + filteredSummary(Set.empty)
  }

  /** Get summary of current parser state, hididng values for some of the options.
    * Useful if you log the summary and want to avoid storing sensitive information
    * in the logs (like passwords)
    *
    * @param blurred names of the options that should be hidden.
    * @return a list of all options in the builder
    */
  def filteredSummary(blurred: Set[String]): String = {
    lazy val hide = "************"
    opts.map { o =>
      Util.format(
        " %s  %s => %s",
        (if (isSupplied(o.name)) "*" else " "),
        o.name,
        if(!blurred.contains(o.name)) get(o.name).getOrElse("") else hide
      )
    }.mkString("\n") + "\n" + parsed.subcommand.map { sn =>
      Util.format("subcommand: %s\n", sn) + subbuilders.find(_._1 == sn).get._2.args(parsed.subcommandArgs).filteredSummary(blurred)
    }.getOrElse("")
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy