From 7ed3fc051c8c7b74f395fac7b763a36a8e4a42e5 Mon Sep 17 00:00:00 2001 From: zhongl Date: Sat, 2 Jun 2012 21:24:47 +0800 Subject: [PATCH] Seperate from HouseMD. --- .gitignore | 7 + LICENSE.txt | 202 ++++++++++++++++++ README.md | 90 ++++++++ build.sbt | 20 ++ .../github/zhongl/yascli/Application.scala | 36 ++++ .../com/github/zhongl/yascli/Command.scala | 144 +++++++++++++ .../zhongl/yascli/CommandException.scala | 29 +++ .../com/github/zhongl/yascli/Converters.scala | 30 +++ .../com/github/zhongl/yascli/PrintOut.scala | 38 ++++ .../com/github/zhongl/yascli/Shell.scala | 104 +++++++++ .../com/github/zhongl/yascli/Suite.scala | 60 ++++++ .../github/zhongl/yascli/SuiteAppcation.scala | 38 ++++ .../zhongl/yascli/ApplicationSpec.scala | 63 ++++++ .../github/zhongl/yascli/CommandSpec.scala | 180 ++++++++++++++++ .../com/github/zhongl/yascli/ShellSpec.scala | 97 +++++++++ .../com/github/zhongl/yascli/SuiteSpec.scala | 48 +++++ 16 files changed, 1186 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 build.sbt create mode 100644 src/main/scala/com/github/zhongl/yascli/Application.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/Command.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/CommandException.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/Converters.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/PrintOut.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/Shell.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/Suite.scala create mode 100644 src/main/scala/com/github/zhongl/yascli/SuiteAppcation.scala create mode 100644 src/test/scala/com/github/zhongl/yascli/ApplicationSpec.scala create mode 100644 src/test/scala/com/github/zhongl/yascli/CommandSpec.scala create mode 100644 src/test/scala/com/github/zhongl/yascli/ShellSpec.scala create mode 100644 src/test/scala/com/github/zhongl/yascli/SuiteSpec.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad641a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.idea/ +*.iml +.classpath +.project + +.#* diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..791995c --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +Yascli 是从[HouseMD](http://github.com/zhongl/housemd)中独立出来的scala的命令行开发包. +相比较已有的类似开源项目, yascli的优势在于: + +- 声明式编程, 编写简单, 阅读易懂 +- 支持多种命令行模式扩展: + 1. 单一命令行, 如 `rm -r d*` + 1. 组命令行, 如 `git add` + 1. 交互命令行, 如 `ftp> help` + +# 从源码安装 + +1. `git clone` 到本地 +1. `sbt publish-local` 构建并发布到本地ivy库, 或是 `sbt publish` 发布到本地maven库 + +> 没有`sbt`? 可以在[这里](http://www.scala-sbt.org/download.html)下载安装`sbt` + +# 配置依赖 + +## SBT + +`libraryDependencies += "com.github.zhongl" %% "yascli" % "0.0.1"` + +## MAVEN + + + com.github.zhong + yascli_2.9.2 + 0.0.1 + + +# 入门 + + import com.github.zhongl.yascli._ + import com.github.zhongl.yascli.Converters._ + + object Example extends Command(name = "example", description = "a example of single command") with Application { + private val flag0 = flag("-f" :: "--flag" :: Nil, "enable flag") + private val singleValue = option[String]("--single-value" :: Nil, "set single value", "value") + private val param = parameter[String]("param", "set param") + + override def run() { + if (flag0()) println("enable flag0.") + println(singleValue()) + println(param()) + } + } + +上面是常见的单一命令行例子, 命令行有一个开关选项`flag0`, 有一个单值选项`singleValue`, 和一个必要的参数`param`. 若运行命令行是: + + > example -f --single-value hello world + +则会打印输出: + + enable flag0 + hello + world + +若要打印帮助信息, 则可运行命令行: + + > example -h + +则结果会是: + + Usage: example [OPTIONS] param + a example of single command + Options: + -f, --flag + enable flag + --single-value=[STRING] + set single value + default: value + Parameters: + param + set param + +这是最简单的例子, yascli支持丰富的扩展: + +1. 值类型转换 +1. 值有效校验 +1. 可选参数 +1. 多值参数 +1. 组命令行 +1. 交互式命令, 参数`tab`自动补全等 + +更多例子, 请见[测试用例]() + +# 类似开源项目 + +请见[StackOverflow](http://stackoverflow.com/questions/2315912/scala-best-way-to-parse-command-line-parameters-cli) + diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..50ee1f8 --- /dev/null +++ b/build.sbt @@ -0,0 +1,20 @@ +name := "yascli" + +organization := "com.github.zhongl" + +version := "0.0.1" + +scalaVersion := "2.9.2" + +crossScalaVersions := Seq("2.9.0","2.9.1","2.9.2") + +libraryDependencies := Seq( + "jline" % "jline" % "2.6", + "org.mockito" % "mockito-all" % "1.9.0" % "test", + "org.scalatest" %% "scalatest" % "1.7.2" % "test" +) + +publishMavenStyle := true + +publishTo := Some(Resolver.file("file", new File(Path.userHome.absolutePath+"/.m2/repository"))) + diff --git a/src/main/scala/com/github/zhongl/yascli/Application.scala b/src/main/scala/com/github/zhongl/yascli/Application.scala new file mode 100644 index 0000000..711c80a --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/Application.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +/** +* @author zhongl +*/ +trait Application {self: Command => + + private val printHelp = flag("-h" :: "--help" :: Nil, "show help infomation of this command.") + + def main(arguments: Array[String]) { + try { + parse(arguments) + if (printHelp()) println(help) else run() + } catch { + case UnknownOptionException(option) => println("Unknown option: " + option) + case MissingParameterException(parameter) => println("Missing parameter: " + parameter) + case ConvertingException(id, value, explain) => println("Invalid " + id + " value: " + value + explain) + } + } +} diff --git a/src/main/scala/com/github/zhongl/yascli/Command.scala b/src/main/scala/com/github/zhongl/yascli/Command.scala new file mode 100644 index 0000000..e4cef0b --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/Command.scala @@ -0,0 +1,144 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import collection.mutable.{ListBuffer, Map} +import annotation.tailrec + +/** + * @author zhongl + */ +abstract class Command(val name: String, val description: String, val out: PrintOut = PrintOut(System.out)) extends Runnable { + + private val options = ListBuffer.empty[Option[_]] + private val parameters = ListBuffer.empty[Parameter[_]] + private val values = Map.empty[String, String] + private val CR = System.getProperty("line.separator") + + implicit private val enhanceBoolean = (b: Boolean) => new {def ?(t: => String, f: => String = "") = if (b) t else f} + + def help = "Usage: " + name + + !options.isEmpty ? (" [OPTIONS]") + + !parameters.isEmpty ? parameters.map(_.repr).mkString(" ", " ", "") + + "\n\t" + description + + !options.isEmpty ? ("\nOptions:\n" + options.mkString("\n")) + + !parameters.isEmpty ? ("\nParameters:\n" + parameters.mkString("\n")) + + def parse(arguments: Array[String]) { + + @tailrec + def read(list: List[String])(implicit index: Int = 0) { + list match { + case head :: tail if (head.matches("-[a-zA-Z-]+")) => read(addOption(head, tail)) + case head :: tail => read(addParameter(index, list))(index + 1) + case Nil => // end recusive + } + } + + read(arguments.toList) + } + + protected final def info(s: Any) { println("INFO: " + s) } + + protected final def warn(s: Any) { println("WARN: " + s) } + + protected final def error(s: Any) { println("ERROR: " + s) } + + protected final def print(a: Any) { out.print(a.toString) } + + protected final def println() { print(CR) } + + protected final def println(a: Any) { print(a.toString + CR) } + + protected final def flag(names: List[String], description: String) = + option[Boolean](names, description, false)(manifest[Boolean], Converters.string2Boolean) + + protected final def option[T](names: List[String], description: String, defaultValue: T) + (implicit m: Manifest[T], convert: String => T) = { + + checkIllegalOption(names) + checkDuplicatedOption(names) + options += Option[T](names, description, defaultValue) + () => eval(names(0), Some(defaultValue)) + } + + protected final def parameter[T](name: String, description: String, defaultValue: scala.Option[T] = None) + (implicit m: Manifest[T], convert: String => T) = { + checkDuplicatedParameter(name) + parameters += Parameter[T](name, description, defaultValue.isDefined) + () => eval(name, defaultValue) + } + + private def checkIllegalOption(names: List[String]) { + if (names.isEmpty) throw new IllegalArgumentException("At least one name should be given to option.") + names find (!_.startsWith("-")) match { + case Some(n) => throw new IllegalArgumentException(n + " should starts with '-'.") + case None => // ignore + } + } + + private def checkDuplicatedParameter(s: String) { + if (parameters.find(_.name == name).isDefined) throw new IllegalStateException(s + " have already been used") + } + + private def checkDuplicatedOption[T](names: scala.List[String]) { + options.find(_.names.intersect(names).size > 0) match { + case Some(option) => throw new IllegalStateException(names + " have already been used in " + option.names) + case None => // ignore + } + } + + private def addOption(name: String, rest: List[String]) = options find (_.names.contains(name)) match { + case Some(option) if option.isFlag => values(option.names(0)) = "true"; rest + case Some(option) => values(option.names(0)) = rest.head; rest.tail + case None => throw new UnknownOptionException(name) + } + + private def addParameter(index: Int, arguments: List[String]) = parameters(index) match { + case p if p.isVarLength => values(p.name) = arguments.mkString(" "); Nil + case p => values(p.name) = arguments.head; arguments.tail + } + + private def eval[T](name: String, defaultValue: scala.Option[T]) + (implicit convert: String => T) = values get name match { + case None => defaultValue.getOrElse {throw MissingParameterException(name)} + case Some(value) => try {convert(value)} catch { + case t: Throwable => throw ConvertingException(name, value, t.getMessage) + } + } + + case class Option[T: Manifest](names: List[String], description: String, defaultValue: T) { + override def toString = { + val isNotFlag = !isFlag + + "\t" + names.mkString(", ") + isNotFlag ? ("=[" + manifest[T].erasure.getSimpleName.toUpperCase + "]") + + "\n\t\t" + description + + isNotFlag ? ("\n\t\tdefault: " + defaultValue) + } + + def isFlag = manifest[T].erasure == classOf[Boolean] + } + + case class Parameter[T: Manifest](name: String, description: String, optional: Boolean) { + override def toString = "\t" + name + "\n\t\t" + description + + def repr = { val s = isVarLength ?(name + "...", name); optional ?("[" + s + "]", s) } + + def isVarLength = manifest[T].erasure.isArray + } + +} diff --git a/src/main/scala/com/github/zhongl/yascli/CommandException.scala b/src/main/scala/com/github/zhongl/yascli/CommandException.scala new file mode 100644 index 0000000..3e01f38 --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/CommandException.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +/** + * @author zhongl + */ + +class CommandException extends Exception + +case class ConvertingException(name: String, value: String, explain: String) extends CommandException + +case class MissingParameterException(name: String) extends CommandException + +case class UnknownOptionException(name: String) extends CommandException diff --git a/src/main/scala/com/github/zhongl/yascli/Converters.scala b/src/main/scala/com/github/zhongl/yascli/Converters.scala new file mode 100644 index 0000000..953d252 --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/Converters.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import java.util.regex.Pattern + +/** + * @author zhongl + */ + +object Converters { + implicit val string2Int = (_: String).toInt + implicit val string2Boolean = (_: String).toBoolean + implicit val string2Array = (_: String).split("\\s+") + implicit val string2Pattern = Pattern.compile(_: String) +} diff --git a/src/main/scala/com/github/zhongl/yascli/PrintOut.scala b/src/main/scala/com/github/zhongl/yascli/PrintOut.scala new file mode 100644 index 0000000..4190c7e --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/PrintOut.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import java.io.OutputStream +import jline.console.ConsoleReader + +/** + * @author zhongl + */ + +object PrintOut { + def apply(out: OutputStream) = new PrintOut { + def print(a: Any) { out.write(a.toString.getBytes); out.flush() } + } + + def apply(cr: ConsoleReader) = new PrintOut { + def print(a: Any) { cr.print(a.toString); cr.flush() } + } +} + +trait PrintOut { + def print(a: Any) +} diff --git a/src/main/scala/com/github/zhongl/yascli/Shell.scala b/src/main/scala/com/github/zhongl/yascli/Shell.scala new file mode 100644 index 0000000..8feddaf --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/Shell.scala @@ -0,0 +1,104 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import jline.console.ConsoleReader +import annotation.tailrec +import java.util.List +import jline.console.completer.{NullCompleter, Completer} + +/** + * @author zhongl + */ +abstract class Shell( + name: String, + description: String, + reader: ConsoleReader = new ConsoleReader(System.in, System.out)) + extends Command(name, description, PrintOut(reader)) with Suite { + + final def main(arguments: Array[String]) { interact() } + + override final def run() {} + + protected def prompt: String = name + "> " + + override protected def decorate(list: String) = "\n" + list + "\n" + + private def interact() { + reader.setPrompt(prompt) + reader.addCompleter(DefaultCompleter) + + @tailrec + def parse(line: String) { + if (line == null) return + val array = line.trim.split("\\s+") + try { + run(array.head, array.tail) { name => println("Unknown command: " + name) } + } catch { + case e: QuitException => return + } + parse(reader.readLine()) + } + + parse(reader.readLine()) + } + + object Quit extends Command("quit", "terminate the process.", PrintOut(reader)) { + def run() { throw new QuitException } + } + + class QuitException extends Exception + + trait CommandCompleter extends Completer { + + import collection.JavaConversions._ + + private val RE0 = """\s+""".r + private val RE1 = """\s*(\w+)""".r + private val RE2 = """\s*(\w+)(.+)""".r + + def complete(buffer: String, cursor: Int, candidates: List[CharSequence]) = buffer match { + case null | RE0() => candidates.addAll(commandNames); cursor + case RE1(p) => candidates.addAll(commandNamesStartsWith(p)); cursor - p.length + case RE2(n, p) => argumentComplete(n, p, cursor, candidates) + } + + protected def argumentComplete(name: String, prefix: String, cursor: Int, candidates: List[CharSequence]): Int + + private def commandNamesStartsWith(prefix: String): List[_ <: CharSequence] = + commands.collect { case cl if cl.name.startsWith(prefix) => cl.name }.sorted + + private def commandNames: List[_ <: CharSequence] = commands.map(_.name).sorted + } + + object HelpCompleter extends CommandCompleter { + protected def argumentComplete(name: String, prefix: String, cursor: Int, candidates: List[CharSequence]) = -1 + } + + object DefaultCompleter extends CommandCompleter { + + protected def argumentComplete(name: String, prefix: String, cursor: Int, candidates: List[CharSequence]) = + completerOfCommand(name).complete(prefix, cursor, candidates) + + private def completerOfCommand(name: String): Completer = commands find {_.name == name} match { + case Some(c) if c.isInstanceOf[Completer] => c.asInstanceOf[Completer] + case Some(c) if c == helpCommand => HelpCompleter + case _ => NullCompleter.INSTANCE + } + } + +} diff --git a/src/main/scala/com/github/zhongl/yascli/Suite.scala b/src/main/scala/com/github/zhongl/yascli/Suite.scala new file mode 100644 index 0000000..71259f6 --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/Suite.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + + +/** + * @author zhongl + */ +trait Suite {self: Command => + + protected lazy val helpCommand = new Command("help", "display this infomation.", self.out) { + + private val command = parameter[String]("command", "sub command name.", Some("*")) + + private lazy val length = (name.length :: commands.map(_.name.length)).max + private lazy val pattern = "%1$-" + length + "s\t%2$s\n" + + def list = commands.foldLeft[String]("") { (a, c) => a + pattern.format(c.name, c.description) } + + def helpOf(name: String) = commands find (_.name == name) match { + case Some(c) => c.help + "\n" + case None => throw new IllegalArgumentException("Unknown command: " + name) + } + + def run() { + try { + print(command() match { + case "*" => decorate(list) + case cmd => helpOf(cmd) + }) + } catch {case e: IllegalArgumentException => println(e.getMessage) } + } + } + + def run(command: String, arguments: Array[String])(handleUnknown: String => Unit) { + commands find {_.name == command} match { + case Some(c) => c.parse(arguments); c.run() + case None => handleUnknown(command) + } + } + + protected def commands: List[Command] + + protected def decorate(list: String): String + +} diff --git a/src/main/scala/com/github/zhongl/yascli/SuiteAppcation.scala b/src/main/scala/com/github/zhongl/yascli/SuiteAppcation.scala new file mode 100644 index 0000000..0df2982 --- /dev/null +++ b/src/main/scala/com/github/zhongl/yascli/SuiteAppcation.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import Converters._ + +/** + * @author zhongl + */ +abstract class SuiteAppcation(name: String, description: String, out: PrintOut) + extends Command(name, description, out) with Suite with Application { + + private val command = parameter[String]("command", "sub command name.") + private val arguments = parameter[Array[String]]("arguments", "sub command arguments.", Some(Array())) + + override def run() { + run(command(), arguments()) { name => println("Unknown command: " + name) } + } + + override def help = decorate(helpCommand.list) + + override protected def decorate(list: String) = super.help + "\nCommands:\n" + list + +} diff --git a/src/test/scala/com/github/zhongl/yascli/ApplicationSpec.scala b/src/test/scala/com/github/zhongl/yascli/ApplicationSpec.scala new file mode 100644 index 0000000..4869ca0 --- /dev/null +++ b/src/test/scala/com/github/zhongl/yascli/ApplicationSpec.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import org.scalatest.FunSpec +import org.scalatest.matchers.ShouldMatchers +import java.io.{OutputStream, ByteArrayOutputStream} + +/** + * @author zhongl + */ + +class ApplicationSpec extends FunSpec with ShouldMatchers { + + class Base(out: OutputStream) extends Command("App", "desc", PrintOut(out)) with Application { + + val param = parameter[String]("param", "parameter")(manifest[String], { value: String => + if (value.contains("@")) value else throw new IllegalArgumentException(", it should contains @") + }) + + def run() {} + } + + val help = """Usage: App [OPTIONS] param + | desc + |Options: + | -h, --help + | show help infomation of this command. + |Parameters: + | param + | parameter""".stripMargin.replaceAll(" ", "\t") + "\n" + + describe("Application") { + + it("should print help by short option") { + val bout = new ByteArrayOutputStream() + val app = new Base(bout) + app main (Array("-h")) + bout.toString should be(help) + } + + it("should print help by long option") { + val bout = new ByteArrayOutputStream() + val app = new Base(bout) + app main (Array("--help")) + bout.toString should be(help) + } + } +} diff --git a/src/test/scala/com/github/zhongl/yascli/CommandSpec.scala b/src/test/scala/com/github/zhongl/yascli/CommandSpec.scala new file mode 100644 index 0000000..ed12756 --- /dev/null +++ b/src/test/scala/com/github/zhongl/yascli/CommandSpec.scala @@ -0,0 +1,180 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import org.scalatest.FunSpec +import org.scalatest.matchers.ShouldMatchers +import java.io.File + +/** + * @author zhongl + */ + +class CommandSpec extends FunSpec with ShouldMatchers { + + import Converters._ + + abstract class Base extends Command("app name","some description", PrintOut(System.out)) { + + def run() {} + + def main(arguments: Array[String]) { + parse(arguments) + } + } + + describe("Command") { + + it("should parse parameters") { + val command = new Base { + val param1 = parameter[Int]("param1", "param1 description") + val param2 = parameter[String]("param2", "param2 description") + } + + command main ("123 allen".split("\\s+")) + command param1() should be(123) + command param2() should be("allen") + } + + it("should support var length paramters") { + val command = new Base { + val param1 = parameter[Int]("param1", "param1 description") + val param2 = parameter[Array[String]]("param2", "param2 description") + } + + command main ("123 allen john".split("\\s+")) + command param1() should be(123) + command param2() should be(Array("allen", "john")) + } + + it("should support optional parameter") { + val command = new Base { + val param = parameter[String]("param", "desc", Some("")) + } + + command main (Array()) + command param() should be("") + } + + ignore("should support POSIX-style short options") { + val command = new Base { + val flag1 = flag("-f" :: Nil, "enable flag1") + val flag2 = flag("-F" :: Nil, "enable flag2") + } + + command main ("-fF".split("\\s+")) + command flag1() should be(true) + command flag2() should be(true) + } + + it("should support GNU-style long option") { + val command = new Base { + val flag0 = flag("--flag" :: Nil, "enable flag") + } + + command main ("--flag".split("\\s+")) + command flag0() should be(true) + } + + it("should support single-value option") { + val command = new Base { + val singleValue = option[String]("--single-value" :: Nil, "set single value", "default") + } + + command main ("--single-value v".split("\\s+")) + command singleValue() should be("v") + } + + it("should get help info") { + val command = new Base { + val flag0 = flag("-f" :: "--flag" :: Nil, "enable flag") + val singleValue = option[String]("--single-value" :: Nil, "set single value", "v") + val param1 = parameter[String]("param1", "set param1") + val param2 = parameter[String]("param2", "set param2") + } + + command.help should be( + """Usage: app name [OPTIONS] param1 param2 + | some description + |Options: + | -f, --flag + | enable flag + | --single-value=[STRING] + | set single value + | default: v + |Parameters: + | param1 + | set param1 + | param2 + | set param2""".stripMargin.replaceAll(" ", "\t")) + } + + it("should get help without options and parameters") { + val command = new Base {} + + command.help should be( + """Usage: app name + | some description""".stripMargin.replaceAll(" ", "\t")) + } + + it("should get help indicate var-length optional parameter") { + val command = new Base { + val param = parameter[Array[String]]("param", "var-length optional param", Some(Array())) + } + command.help should be( + """Usage: app name [param...] + | some description + |Parameters: + | param + | var-length optional param""".stripMargin.replaceAll(" ", "\t")) + + } + + it("should complain unknown option") { + val command = new Base {} + + val exception = evaluating {command main ("-u".split("\\s+"))} should produce[UnknownOptionException] + exception.name should be("-u") + } + + it("should complain missing parameter") { + val command = new Base { + val param = parameter[String]("param", "set param") + } + command main (Array()) + val exception = evaluating {command param()} should produce[MissingParameterException] + exception.name should be("param") + } + + it("should complain converting error") { + val command = new Base { + implicit val toFile = { + value: String => + val file = new File(value) + if (file.exists()) file else throw new IllegalArgumentException(", it should be an existed file") + } + val file = option[File]("--file" :: Nil, "set a file", new File("default")) + } + command main ("--file nonexist".split("\\s+")) + val exception = evaluating {command file()} should produce[ConvertingException] + exception.name should be("--file") + exception.value should be("nonexist") + exception.explain should be(", it should be an existed file") + } + } + +} diff --git a/src/test/scala/com/github/zhongl/yascli/ShellSpec.scala b/src/test/scala/com/github/zhongl/yascli/ShellSpec.scala new file mode 100644 index 0000000..0116e98 --- /dev/null +++ b/src/test/scala/com/github/zhongl/yascli/ShellSpec.scala @@ -0,0 +1,97 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import org.scalatest.FunSpec +import org.scalatest.matchers.ShouldMatchers +import java.io.{OutputStream, ByteArrayInputStream, PrintStream, ByteArrayOutputStream} +import jline.console.ConsoleReader + +/** + * @author zhongl + */ +class ShellSpec extends FunSpec with ShouldMatchers { + + class AShell(line: String, out: OutputStream) extends Shell( + name = "shell", + description = "a shell example", + reader = new ConsoleReader(new ByteArrayInputStream(line.getBytes), new PrintStream(out))) { + protected def commands = helpCommand :: Quit :: Nil + } + + private val backspace = "\u001B[K" + + private def moveCursor(i: Int) = "\u001B[" + i + "G" + + describe("Shell") { + + it("should get help") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("help\n", bout) + shell main (Array()) + bout.toString should be( + """shell> help + | + |help display this infomation. + |quit terminate the process. + | + |shell> """.stripMargin) + } + + it("should complete help command") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("h\t", bout) + shell main (Array()) + bout.toString should be("shell> h" + moveCursor(8) + backspace + "help") + } + + it("should complete help command argument") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("help q\t", bout) + shell main (Array()) + bout.toString should be("shell> help q" + moveCursor(13) + backspace + "quit") + } + + it("should complete nothing") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("help help a\t", bout) + shell main (Array()) + bout.toString should be("shell> help help a") + } + + it("should complain unknown command name") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("unknow\n", bout) + shell main (Array()) + bout.toString should be( + """shell> unknow + |Unknown command: unknow + |shell> """.stripMargin) + } + + it("should complain unknown command name in help") { + val bout = new ByteArrayOutputStream() + val shell = new AShell("help unknow\n", bout) + shell main (Array()) + bout.toString should be( + """shell> help unknow + |Unknown command: unknow + |shell> """.stripMargin) + } + } + +} diff --git a/src/test/scala/com/github/zhongl/yascli/SuiteSpec.scala b/src/test/scala/com/github/zhongl/yascli/SuiteSpec.scala new file mode 100644 index 0000000..11f5064 --- /dev/null +++ b/src/test/scala/com/github/zhongl/yascli/SuiteSpec.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2012 zhongl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.zhongl.yascli + +import org.scalatest.FunSpec +import org.scalatest.matchers.ShouldMatchers +import java.io.{OutputStream, ByteArrayOutputStream} + +/** + * @author zhongl + */ +class SuiteSpec extends FunSpec with ShouldMatchers { + + class ACommand(out: PrintOut) extends Command("cmd", "example.", out) { + def run() {} + } + + class ASuiteAppcation(os: OutputStream) + extends SuiteAppcation(name = "acs", description = "A command suite", out = PrintOut(os)) + with Application { + override protected lazy val commands = helpCommand :: new ACommand(out) :: Nil + } + + describe("Suite Application") { + + it("should get help") { + val bout = new ByteArrayOutputStream() + val acs = new ASuiteAppcation(bout) + acs main ("help cmd".split("\\s+")) + bout.toString should be("Usage: cmd\n\texample.\n") + } + } + +}