From a5a2118e4e0f31a4b8ae9921fa634058af526cdc Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Sat, 21 Sep 2019 00:38:30 -0400 Subject: Migrate build to mill; redesign command structure and parser --- src/main/scala/completion/Bash.scala | 107 ------------------ src/main/scala/definitions.scala | 66 ----------- src/main/scala/package.scala | 50 --------- src/main/scala/parsing.scala | 212 ----------------------------------- src/test/scala/ParserTest.scala | 143 ----------------------- 5 files changed, 578 deletions(-) delete mode 100644 src/main/scala/completion/Bash.scala delete mode 100644 src/main/scala/definitions.scala delete mode 100644 src/main/scala/package.scala delete mode 100644 src/main/scala/parsing.scala delete mode 100644 src/test/scala/ParserTest.scala (limited to 'src') diff --git a/src/main/scala/completion/Bash.scala b/src/main/scala/completion/Bash.scala deleted file mode 100644 index b22e054..0000000 --- a/src/main/scala/completion/Bash.scala +++ /dev/null @@ -1,107 +0,0 @@ -package commando -package completion - -object Bash { - - private def addCommands(command: Command): String = { - command.commands.map(c => s"""commands+=("${c.name}")\n""").mkString - } - - private def addFlags(command: Command): String = { - command.optionals.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): String = { - 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 deleted file mode 100644 index 8de9e3a..0000000 --- a/src/main/scala/definitions.scala +++ /dev/null @@ -1,66 +0,0 @@ -package commando - -import commando.completion.Bash - -sealed trait Parameter { - def usage: String -} - -case class Optional(long: String, - short: Option[Char] = None, - argumentAllowed: Boolean = false, - argumentRequired: Boolean = false, - parameterName: String = "param") - extends Parameter { - - def usage: String = { - val shortString = short.map(c => s"-$c|").getOrElse("") - val paramString = if (argumentRequired) { - s"=<$parameterName>" - } else if (argumentAllowed) { - s"[=<$parameterName>]" - } else { - "" - } - s"[$shortString--$long$paramString]" - } -} - -case class Positional(name: String, required: Boolean = true) - extends Parameter { - def usage: String = if (required) s"<$name>" else s"[<$name>]" -} - -case class Command( - name: String, - optionals: Set[Optional], - positionals: Seq[Positional], - commands: Set[Command] = Set.empty, - action: Option[Command.Arguments => Unit] = None -) { - - private def subusage(level: Int): String = { - val optStrings = optionals.map { opt => - opt.usage - } - val posStrings = positionals map { pos => - pos.usage - } - val cmdStrings = Seq(commands.map(cmd => cmd.name).mkString("|")) - - val headline = - (Seq(name) ++ optStrings ++ posStrings ++ cmdStrings).mkString(" ") - val lines = commands - .map(_.subusage(level + 1)) - .map(line => " " * (level + 1) + line) - headline + lines.mkString("\n", "", "") - } - - def usage: String = subusage(0) - - def completion: String = Bash.completion(this) -} - -object Command { - type Arguments = Map[String, Seq[String]] -} diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala deleted file mode 100644 index 68936cd..0000000 --- a/src/main/scala/package.scala +++ /dev/null @@ -1,50 +0,0 @@ -package commando - -class CommandBuilder(name: String, params: Seq[Parameter]) { - - private def optionals = - params.collect { - case opt: Optional => opt - }.toSet - private def positionals = params.collect { - case pos: Positional => pos - } - - def run(action: Command.Arguments => Unit): Command = - Command(name, optionals, positionals, Set.empty, Some(action)) - - def sub(commands: Command*): Command = - Command(name, optionals, positionals, commands.toSet, None) - -} - -object `package` { - - val DefaultErrorHandler: (Command, String) => Unit = - (command: Command, err: String) => { - System.err.println(s"${command.name}: $err") - System.exit(2) - } - - def parse(arguments: Seq[String], - command: Command, - onError: (Command, String) => Unit = DefaultErrorHandler): Unit = - Parser.parse(arguments, command, onError) - - def cmd(name: String)(params: Parameter*): CommandBuilder = - new CommandBuilder(name, params) - def opt(name: String, - short: Char = '\u0000', - param: (String, Boolean) = ("", false)): Optional = - Optional( - name, - if (short == '\u0000') None else Some(short), - argumentAllowed = (param != ("", false)), - argumentRequired = (param != ("", false)) && param._2, - parameterName = if (param._1 == "") "param" else param._1 - ) - - def pos(name: String, required: Boolean = true): Positional = - Positional(name, required) - -} diff --git a/src/main/scala/parsing.scala b/src/main/scala/parsing.scala deleted file mode 100644 index 2526d39..0000000 --- a/src/main/scala/parsing.scala +++ /dev/null @@ -1,212 +0,0 @@ -package commando -import scala.collection.mutable - -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]): Iterator[Token] = new Iterator[Token] { - val args = input.iterator - val shortOptions = new mutable.Queue[Token] - - var escaping = false - override def hasNext: Boolean = args.hasNext || shortOptions.nonEmpty - - override def next(): Token = - if (shortOptions.nonEmpty) { - 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(args: Seq[String], - command: Command, - onError: (Command, String) => Unit): Unit = - 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 - } - - val _parsedArguments = new mutable.HashMap[String, List[String]] - def addArgument(name: String, value: String): Unit = - _parsedArguments.get(name) match { - case None => _parsedArguments(name) = value :: Nil - case Some(values) => _parsedArguments(name) = value :: values - } - def parsedArguments = - _parsedArguments.map { - case (key, values) => - key -> values.reverse.toSeq - }.toMap - - def line(command: Command): Unit = { - val longs: Map[String, Optional] = command.optionals.map { - case opt: Optional => opt.long -> opt - }.toMap - val shorts: Map[String, Optional] = command.optionals.collect { - case opt: Optional if opt.short.isDefined => - opt.short.get.toString -> opt - }.toMap - val remainingPositionals = command.positionals.collect { - case pos: Positional => pos - }.iterator - val subcommands: Map[String, Command] = command.commands.map { cmd => - cmd.name -> cmd - }.toMap - - def fatal(message: String) = throw new ParseException(message) - - def optional(): Unit = { - val tok = accept() - val parts = tok.value.split("=", 2) - val name = parts(0) - val embedded: Option[String] = - if (parts.size > 1) Some(parts(1)) else None - val opt = (tok.kind: @unchecked) match { - case LONG => - longs.getOrElse(name, fatal(s"unknown option '--$name'")) - case SHORT => - shorts.getOrElse(name, fatal(s"unknown option '-$name'")) - } - - if (opt.argumentRequired) { - embedded match { - case Some(value) => - addArgument(opt.long, value) - case None if token.kind == POSITIONAL => - addArgument(opt.long, accept().value) - case None => - fatal( - s"option ${opt.usage} requires an argument but ${token.value} found") - } - } else if (opt.argumentAllowed) { - embedded match { - case Some(value) => - addArgument(opt.long, value) - case None => - if (token.kind == POSITIONAL) { - addArgument(opt.long, accept().value) - } else { - addArgument(opt.long, "") - } - } - } else { // no argument allowed - embedded match { - case Some(value) => - fatal( - s"no argument allowed for option ${opt.usage} (it is set to $value)") - case None => addArgument(opt.long, "") - } - } - } - - def positional(): Unit = { - if (remainingPositionals.hasNext) { - addArgument( - remainingPositionals.next.name, - accept().value - ) - } else { - fatal(s"too many arguments: '${token.value}'") - } - } - - // make sure all required positional parameters have been parsed - def checkPositionals(): Unit = { - val remaining = remainingPositionals.toList - if (!remaining.forall(_.required == false)) { - val missing = remaining.map(p => s"'${p.name}'") - fatal(s"missing parameter(s) ${missing.mkString(", ")}") - } - } - - var escaping = false - @annotation.tailrec - def innerLine(): Unit = { - if (token.kind == EOL) { - checkPositionals() - if (subcommands.nonEmpty) { - val missing = command.commands.map(c => s"'${c.name}'") - fatal( - s"command not specified (must be one of ${missing.mkString(", ")})") - } - command.action.get(parsedArguments) - } else if (escaping) { - positional() - innerLine() - } else if (token.kind == DOUBLE_DASH) { - escaping = true - readToken() - innerLine() - } else if (token.kind == POSITIONAL && remainingPositionals.nonEmpty) { - positional() - innerLine() - } else if (token.kind == POSITIONAL) { - if (subcommands.isEmpty) { - fatal(s"too many arguments: '${token.value}'") - } else { - subcommands.get(token.value) match { - case None => - val cmds = command.commands.map(c => s"'${c.name}'") - fatal( - s"command '${token.value}' not found (must be one of ${cmds - .mkString(", ")})") - case Some(cmd) => - checkPositionals() - readToken() - line(cmd) - } - } - } else if (token.kind == LONG || token.kind == SHORT) { - optional() - innerLine() - } else { - fatal(s"unknown token $token") - } - } - innerLine() - } - readToken() - line(command) - } catch { - case ex: ParseException => onError(command, ex.getMessage) - } - -} diff --git a/src/test/scala/ParserTest.scala b/src/test/scala/ParserTest.scala deleted file mode 100644 index 579ba95..0000000 --- a/src/test/scala/ParserTest.scala +++ /dev/null @@ -1,143 +0,0 @@ -package commando - -import utest._ - -object ParserTest extends TestSuite { - - implicit class EliteCommando(line: String) { - def parse(command: Command): Unit = { - val args = line.split(" ").tail - commando.parse(args, command, (c, err) => throw new ParseException(err)) - } - def fail(command: Command, message: String): Unit = { - val args = line.split(" ").tail - try { - commando.parse(args, command, (c, err) => throw new ParseException(err)) - sys.error("parsing succeeded but was expected to fail") - } catch { - case err: ParseException if err.getMessage.contains(message) => - case err: ParseException => - sys.error(s"parsing failed for the wrong reason: ${err.getMessage}") - } - } - } - - def cbx(asserts: Command.Arguments => Unit) = - cmd("cbx")( - opt("server", 's', "name" -> true) - ).sub( - cmd("version")( - opt("verbose", 'v', "k=v" -> false) - ).run(asserts), - cmd("login")( - pos("server_url"), - pos("username", false), - pos("password", false) - ).run(asserts), - cmd("run")( - opt("file", 'f', "file_name" -> true), - opt("force"), - pos("pipeline", false) - ).run(asserts), - cmd("level1")().sub( - cmd("level2-1")( - pos("p2") - ).sub( - cmd("level3")(pos("p3")).run(asserts) - ), - cmd("level2-2")().run(asserts) - ) - ) - - val tests = Tests { - "print usage" - { - cbx(_ => ()).usage - } - "simple" - { - "cbx version" parse cbx { args => - args ==> Map.empty - } - } - "empty allowed optional" - { - "cbx version -v" parse cbx { args => - args ==> Map("verbose" -> Seq("")) - } - "cbx version --verbose" parse cbx { args => - args ==> Map("verbose" -> Seq("")) - } - } - "set allowed optional" - { - "cbx version -v x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose=x" parse cbx { args => - args ==> Map("verbose" -> Seq("x")) - } - "cbx version --verbose=x=y" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y")) - } - "cbx version --verbose=x=y,z=w" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y,z=w")) - } - "cbx version --verbose x=y" parse cbx { args => - args ==> Map("verbose" -> Seq("x=y")) - } - "cbx version --verbose x=y z=w".fail(cbx(_ => ()), "too many arguments") - } - "required argument optional" - { - "cbx run" parse cbx { _ ==> Map.empty } // make sure it works first - "cbx run -f x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file=x" parse cbx { _ ==> Map("file" -> Seq("x")) } - "cbx run --file=x=y,z=w" parse cbx { _ ==> Map("file" -> Seq("x=y,z=w")) } - "cbx run --file".fail(cbx(_ => ()), "requires") - "cbx run --file --".fail(cbx(_ => ()), "requires") - } - "no argument optional" - { - "cbx run --force=x".fail(cbx(_ => ()), "no argument allowed") - "cbx run --force x" parse cbx { _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("x")) } - } - "global optional" - { - "cbx --server run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx -s run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx --server=run run" parse cbx {_ ==> Map("server" -> Seq("run"))} - "cbx -x run".fail(cbx(_ => ()), "unknown option") - "cbx --x run".fail(cbx(_ => ()), "unknown option") - } - "positional" - { - "cbx login x" parse cbx { _ ==> Map("server_url" -> Seq("x"))} - "cbx login x y" parse cbx { _ ==> Map("server_url" -> Seq("x"), "username" -> Seq("y"))} - "cbx login x y z" parse cbx { _ ==> Map("server_url" -> Seq("x"), "username" -> Seq("y"), "password" -> Seq("z"))} - "cbx login - x y z".fail(cbx(_ => ()), "too many") - "cbx login - y" parse cbx { _ ==> Map("server_url" -> Seq("-"), "username" -> Seq("y"))} - } - "out of order options" - { - "cbx run --force pipelinename -f x" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("pipelinename"), "file" -> Seq("x")) - } - "cbx run --force -- -f" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("-f")) - } - "cbx run --force -- --file" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("--file")) - } - "cbx run --force -- --" parse cbx { - _ ==> Map("force" -> Seq(""), "pipeline" -> Seq("--")) - } - "cbx run --force -- -f x".fail(cbx(_ => ()), "too many") - } - "nested1" - { - "cbx level1 level2-1 x=y level3 z" parse cbx { - _ ==> Map("p2" -> Seq("x=y"), "p3" -> Seq("z")) - } - } - "nested2" - { - "cbx level1 level2-2 --" parse cbx { - _ ==> Map.empty - } - } - } -} -- cgit v1.2.3