From d542dca72c2abefc4a3bb2618fa0b1743ab2a7a6 Mon Sep 17 00:00:00 2001 From: Jim Schubert Date: Sun, 11 Dec 2016 22:09:28 -0500 Subject: [PATCH] Return ArgumentCollection from parse This separates the definition of options (what's available to a user) from the associated arguments (what's provided by the user). Included documentation on public methods. See #2 --- gradle/wrapper/gradle-wrapper.properties | 4 +- .../kotlin/us/jimschubert/kopper/cli/App.kt | 18 ++-- .../kopper/typed/BooleanArgument.kt | 6 +- .../kopper/typed/NumericArgument.kt | 16 ++-- .../kopper/typed/StringArgument.kt | 6 +- .../kopper/typed/TypedArgumentParser.kt | 20 +++-- .../kotlin/us/jimschubert/kopper/Argument.kt | 14 +++ .../jimschubert/kopper/ArgumentCollection.kt | 25 ++++++ .../kotlin/us/jimschubert/kopper/Option.kt | 15 +++- .../kotlin/us/jimschubert/kopper/Parser.kt | 86 ++++++++++++++----- .../us/jimschubert/kopper/StringExtensions.kt | 7 +- .../kopper/ArgumentCollectionTest.kt | 45 ++++++++++ .../us/jimschubert/kopper/ParserTest.kt | 60 ++++++------- 13 files changed, 240 insertions(+), 82 deletions(-) create mode 100644 kopper/src/main/kotlin/us/jimschubert/kopper/Argument.kt create mode 100644 kopper/src/main/kotlin/us/jimschubert/kopper/ArgumentCollection.kt create mode 100644 kopper/src/test/kotlin/us/jimschubert/kopper/ArgumentCollectionTest.kt diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 43ca251..f1ff9bb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Dec 05 21:26:08 EST 2016 +#Sun Dec 11 20:37:03 EST 2016 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-3.2-bin.zip diff --git a/kopper-cli/src/main/kotlin/us/jimschubert/kopper/cli/App.kt b/kopper-cli/src/main/kotlin/us/jimschubert/kopper/cli/App.kt index 8e8342f..ca886ee 100644 --- a/kopper-cli/src/main/kotlin/us/jimschubert/kopper/cli/App.kt +++ b/kopper-cli/src/main/kotlin/us/jimschubert/kopper/cli/App.kt @@ -12,18 +12,18 @@ fun main(args: Array) { parser.flag("a", listOf("allowEmpty")) parser.flag("h", listOf("help"), description = "Displays this message") - parser.parse(arrayOf("-f", "asdf.txt", "--quiet=true", "trailing", "arguments" )) + val options = parser.parse(arrayOf("-f", "asdf.txt", "--quiet=true", "trailing", "arguments" )) - if(parser.isSet("h")) { + if(options.flag("h")) { println(parser.printHelp()) return } - println("q=${parser.isSet("q")}") - println("quiet=${parser.isSet("quiet")}") - println("silent=${parser.isSet("silent")}") - println("f=${parser.get("f")}") - println("file=${parser.get("file")}") - println("allowEmpty=${parser.isSet("allowEmpty")}") - println("remainingArgs=${parser.remainingArgs.joinToString()}") + println("q=${options.flag("q")}") + println("quiet=${options.flag("quiet")}") + println("silent=${options.flag("silent")}") + println("f=${options.option("f")}") + println("file=${options.option("file")}") + println("allowEmpty=${options.flag("allowEmpty")}") + println("unparsedArgs=${options.unparsedArgs.joinToString()}") } \ No newline at end of file diff --git a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/BooleanArgument.kt b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/BooleanArgument.kt index 356b5a6..2a8b803 100644 --- a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/BooleanArgument.kt +++ b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/BooleanArgument.kt @@ -3,6 +3,9 @@ package us.jimschubert.kopper.typed import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty +/** + * Delegates argument parsing of a boolean option + */ class BooleanArgument( caller: TypedArgumentParser, val shortOption: String, @@ -21,7 +24,6 @@ class BooleanArgument( * @return the property value. */ override fun getValue(thisRef: TypedArgumentParser, property: KProperty<*>): Boolean { - thisRef.ensureParsed() - return thisRef.parser.isSet(shortOption) + return thisRef.ensureParsed().flag(shortOption) } } \ No newline at end of file diff --git a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/NumericArgument.kt b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/NumericArgument.kt index 83bcd80..1a14c47 100644 --- a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/NumericArgument.kt +++ b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/NumericArgument.kt @@ -3,6 +3,9 @@ package us.jimschubert.kopper.typed import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty +/** + * Delegates argument parsing of a numeric option defined by @param[T] + */ class NumericArgument( caller: TypedArgumentParser, val shortOption: String, @@ -23,14 +26,13 @@ class NumericArgument( */ @Suppress("UNCHECKED_CAST") override fun getValue(thisRef: TypedArgumentParser, property: KProperty<*>): T? { - thisRef.ensureParsed() when (default) { - is Float -> return thisRef.parser.get(shortOption)?.toFloat() as T? ?: default - is Long -> return thisRef.parser.get(shortOption)?.toLong() as T? ?: default - is Int -> return thisRef.parser.get(shortOption)?.toInt() as T? ?: default - is Double -> return thisRef.parser.get(shortOption)?.toDouble() as T? ?: default - is Byte -> return thisRef.parser.get(shortOption)?.toByte() as T? ?: default - is Short -> return thisRef.parser.get(shortOption)?.toShort() as T? ?: default + is Float -> return thisRef.ensureParsed().option(shortOption)?.toFloat() as T? ?: default + is Long -> return thisRef.ensureParsed().option(shortOption)?.toLong() as T? ?: default + is Int -> return thisRef.ensureParsed().option(shortOption)?.toInt() as T? ?: default + is Double -> return thisRef.ensureParsed().option(shortOption)?.toDouble() as T? ?: default + is Byte -> return thisRef.ensureParsed().option(shortOption)?.toByte() as T? ?: default + is Short -> return thisRef.ensureParsed().option(shortOption)?.toShort() as T? ?: default } return null diff --git a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/StringArgument.kt b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/StringArgument.kt index 62db8e8..caca005 100644 --- a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/StringArgument.kt +++ b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/StringArgument.kt @@ -4,6 +4,9 @@ import us.jimschubert.kopper.StringOption import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty +/** + * Delegates argument parsing of a string option + */ class StringArgument( caller: TypedArgumentParser, val shortOption: String, @@ -23,7 +26,6 @@ class StringArgument( * @return the property value. */ override fun getValue(thisRef: TypedArgumentParser, property: KProperty<*>): String { - thisRef.ensureParsed() - return thisRef.parser.get(shortOption) ?: "" + return thisRef.ensureParsed().option(shortOption) ?: "" } } \ No newline at end of file diff --git a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/TypedArgumentParser.kt b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/TypedArgumentParser.kt index ad498ed..f98ce01 100644 --- a/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/TypedArgumentParser.kt +++ b/kopper-typed/src/main/kotlin/us/jimschubert/kopper/typed/TypedArgumentParser.kt @@ -1,21 +1,29 @@ package us.jimschubert.kopper.typed +import us.jimschubert.kopper.ArgumentCollection import us.jimschubert.kopper.Parser +/** + * A base type for object-oriented argument parsing. + * + * Use delegated properties defined by, e.g. [BooleanArgument], [NumericArgument], [StringArgument] + */ abstract class TypedArgumentParser(val args: Array) { - private var hasParsed = false + private var arguments: ArgumentCollection? = null val self: TypedArgumentParser by lazy { this } internal val parser = Parser() - internal fun ensureParsed() { - if(!hasParsed) { - parser.parse(args) - hasParsed = true + internal fun ensureParsed() : ArgumentCollection { + if(arguments == null) { + arguments = parser.parse(args) } + return arguments!! } fun printHelp() : String { return parser.printHelp() } - val _etc_: List get() = parser.remainingArgs + val _etc_: List get() { + return ensureParsed().unparsedArgs + } } \ No newline at end of file diff --git a/kopper/src/main/kotlin/us/jimschubert/kopper/Argument.kt b/kopper/src/main/kotlin/us/jimschubert/kopper/Argument.kt new file mode 100644 index 0000000..ad02857 --- /dev/null +++ b/kopper/src/main/kotlin/us/jimschubert/kopper/Argument.kt @@ -0,0 +1,14 @@ +package us.jimschubert.kopper + +/** + * Represents an actual value passed via command line arguments, including the option + * responsible for parsing the argument, and the args list that provided options and values. + */ +data class Argument(val value: T?, val option: Option<*>, val args: List = listOf()) + +/** + * Merges a newer argument with an older argument + */ +operator fun Argument.plus(newer: Argument): Argument { + return copy(value = newer.value, args = args + newer.args) +} \ No newline at end of file diff --git a/kopper/src/main/kotlin/us/jimschubert/kopper/ArgumentCollection.kt b/kopper/src/main/kotlin/us/jimschubert/kopper/ArgumentCollection.kt new file mode 100644 index 0000000..cd14fef --- /dev/null +++ b/kopper/src/main/kotlin/us/jimschubert/kopper/ArgumentCollection.kt @@ -0,0 +1,25 @@ +package us.jimschubert.kopper + +/** + * Represents a collection of arguments provided by the user. + * + * Includes helper methods for querying by option name (short or long). + * + * @note Maintains a bucket of unparsed arguments + */ +class ArgumentCollection(val unparsedArgs: List, + parsedArgumentList: List> +) : List> by parsedArgumentList { + fun option(name: String): String? { + val found: Argument<*>? = find { (it.option.shortOption == name || it.option.longOption.contains(name)) } + return (found?.value as? String?) + } + + fun flag(name: String): Boolean { + val found = find { + it.option.isFlag && (it.option.shortOption == name || it.option.longOption.contains(name)) + } ?: return false + + return found.value as? Boolean ?: false + } +} \ No newline at end of file diff --git a/kopper/src/main/kotlin/us/jimschubert/kopper/Option.kt b/kopper/src/main/kotlin/us/jimschubert/kopper/Option.kt index 93f39d9..e240c7c 100644 --- a/kopper/src/main/kotlin/us/jimschubert/kopper/Option.kt +++ b/kopper/src/main/kotlin/us/jimschubert/kopper/Option.kt @@ -1,5 +1,8 @@ package us.jimschubert.kopper +/** + * A generic representation of a command line option + */ abstract class Option( open val shortOption: String, open val longOption: List = listOf(), @@ -44,15 +47,20 @@ abstract class Option( return (value as? T?) ?: default } - internal fun applyParsedOption(value: String?): Unit { + internal fun applyParsedOption(value: String?): T? { actual = parseOption(value) + return actual } - internal fun setAsDefault() { + internal fun setAsDefault(): T? { this.actual = this.default + return this.actual } } +/** + * A command line option represented as a string + */ class StringOption( override val shortOption: String, override val longOption: List = listOf(), @@ -67,6 +75,9 @@ class StringOption( false ) +/** + * A command line option represented as a true/false value + */ class BooleanOption( override val shortOption: String, override val longOption: List = listOf(), diff --git a/kopper/src/main/kotlin/us/jimschubert/kopper/Parser.kt b/kopper/src/main/kotlin/us/jimschubert/kopper/Parser.kt index 318f829..a376f32 100644 --- a/kopper/src/main/kotlin/us/jimschubert/kopper/Parser.kt +++ b/kopper/src/main/kotlin/us/jimschubert/kopper/Parser.kt @@ -1,19 +1,28 @@ package us.jimschubert.kopper +/** + * Defines how a set of command line options will be parsed into the arguments that were passed + */ class Parser { private var options: MutableList> = mutableListOf() private var _args: MutableList = mutableListOf() private var _name: String? = null + + /** + * The name of the application + */ val name: String? get() = _name private var _applicationDescription: String? = null + + /** + * A description of the application + */ val applicationDescription: String? get() = _applicationDescription - val remainingArgs: List get() = _args.toList() - fun option(shortOption: String, longOption: List = listOf(), description: String? = null, @@ -23,16 +32,25 @@ class Parser { return this } + /** + * Sets the application name + */ fun setName(name: String) : Parser { _name = name return this } + /** + * Sets the application description + */ fun setApplicationDescription(description: String) : Parser { _applicationDescription = description return this } + /** + * Defines a flag/switch represented as a true or false value + */ fun flag(shortOption: String, longOption: List = listOf(), description: String? = null, @@ -42,52 +60,80 @@ class Parser { return this } + /** + * Allows for defining custom options derived from [Option] + */ fun custom(option: Option): Parser { options.add(option) return this } - fun parse(args: Array) { + /** + * Parses input args into a collection of arguments with metadata about how those arguments were parsed. + */ + fun parse(args: Array): ArgumentCollection { + _args.clear() + val passedArguments : MutableList> = mutableListOf() var argIndex = 0 + while(argIndex < args.size) { val s = args[argIndex] if(s.startsWith("--")) { val (left,right) = s.removePrefix("--").kvp() val option = options.find { it.longOption.contains(left) } - if(false == option?.isFlag) + val result = if(false == option?.isFlag) { option?.applyParsedOption(right) - else + } + else { option?.applyParsedOption(right) + } + + if(option != null) { + val usedArgs = if (right != null) listOf(left, right) else listOf(left) + val prev = passedArguments.find { it.option == option } + val current = Argument(result, option, usedArgs) + if(prev != null) { + passedArguments.remove(prev) + passedArguments.add(prev + current) + } else { + passedArguments.add(current) + } + } } else if (s.startsWith("-")) { val next = if(argIndex < (args.size-1)) args[argIndex+1] else null val option = options.find { it.shortOption == s.removePrefix("-")} - if(false == next?.startsWith("-")) { - option?.applyParsedOption(next) + val hasFollowingOption = next?.startsWith("-") + val result = if(false == hasFollowingOption) { argIndex++ - } else if (option != null) { + option?.applyParsedOption(next) + } else { option?.setAsDefault() } + + if(option != null) { + val usedArgs = if(false == hasFollowingOption) listOf(next!!) else listOf() + val prev = passedArguments.find { it.option == option } + val current = Argument(result, option, usedArgs) + if(prev != null) { + passedArguments.remove(prev) + passedArguments.add(prev + current) + } else { + passedArguments.add(current) + } + } } else _args.add(s) argIndex++ } - } - - fun get(option: String): String? { - val found = options.find { (it.shortOption == option || it.longOption.contains(option)) } - return found?.actual as? String? - } - - fun isSet(option: String): Boolean { - val found = options.find { - it.isFlag && (it.shortOption == option || it.longOption.contains(option)) - } as? BooleanOption ?: return false - return found.actual ?: false + return ArgumentCollection(_args.toList(), passedArguments) } + /** + * Provides a formatted string for printing a help message to a user. + */ fun printHelp(): String { val buffer = StringBuffer() if(name != null) { diff --git a/kopper/src/main/kotlin/us/jimschubert/kopper/StringExtensions.kt b/kopper/src/main/kotlin/us/jimschubert/kopper/StringExtensions.kt index c6f074e..0dbd822 100644 --- a/kopper/src/main/kotlin/us/jimschubert/kopper/StringExtensions.kt +++ b/kopper/src/main/kotlin/us/jimschubert/kopper/StringExtensions.kt @@ -1,7 +1,10 @@ package us.jimschubert.kopper -fun String.kvp(): Pair { - val idx = indexOfFirst { it == '=' } +/** + * Splits a string on the first occurrence of a separator and returns a pair of key -> value? + */ +fun String.kvp(separator: Char = '='): Pair { + val idx = indexOfFirst { it == separator } if(idx > 1) { val last = idx+1 return Pair(substring(0, idx), if(last==length) null else substring(last, length)) diff --git a/kopper/src/test/kotlin/us/jimschubert/kopper/ArgumentCollectionTest.kt b/kopper/src/test/kotlin/us/jimschubert/kopper/ArgumentCollectionTest.kt new file mode 100644 index 0000000..4e55cd8 --- /dev/null +++ b/kopper/src/test/kotlin/us/jimschubert/kopper/ArgumentCollectionTest.kt @@ -0,0 +1,45 @@ +package us.jimschubert.kopper + +import org.testng.Assert.* +import org.testng.annotations.Test + +class ArgumentCollectionTest { + @Test + fun `ArgumentCollection should act like a collection`(){ + // Arrange + val arguments = listOf( + Argument(true, BooleanOption("a")), + Argument(false, BooleanOption("b")), + Argument(true, BooleanOption("ab")), + Argument(false, BooleanOption("c")) + ) + val argumentCollection = ArgumentCollection(listOf(), arguments) + + // Act + val trues = argumentCollection.filter { arg -> arg.value == true } + + // Assert + assertEquals(trues.size, 2) + } + + @Test + fun `ArgumentCollection should offer helpers for options and flags`(){ + // Arrange + val arguments = listOf( + Argument(true, BooleanOption("a")), + Argument("some_name", StringOption("name")), + Argument(false, BooleanOption("z")) + ) + val argumentCollection = ArgumentCollection(listOf(), arguments) + + // Act + val a = argumentCollection.flag("a") + val name = argumentCollection.option("name") + val z = argumentCollection.flag("z") + + // Assert + assertTrue(a) + assertEquals(name, "some_name") + assertFalse(z) + } +} \ No newline at end of file diff --git a/kopper/src/test/kotlin/us/jimschubert/kopper/ParserTest.kt b/kopper/src/test/kotlin/us/jimschubert/kopper/ParserTest.kt index 58cb167..0f63fc6 100644 --- a/kopper/src/test/kotlin/us/jimschubert/kopper/ParserTest.kt +++ b/kopper/src/test/kotlin/us/jimschubert/kopper/ParserTest.kt @@ -24,11 +24,11 @@ class ParserTest { val args = arrayOf("-f", filename) // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.get("f"), filename) - assertEquals(parser.get("file"), filename) + assertEquals(arguments.option("f"), filename) + assertEquals(arguments.option("file"), filename) } @Test @@ -37,11 +37,11 @@ class ParserTest { val args = arrayOf("-f") // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.get("f"), null) - assertEquals(parser.get("file"), null) + assertEquals(arguments.option("f"), null) + assertEquals(arguments.option("file"), null) } @Test @@ -50,11 +50,11 @@ class ParserTest { val args = arrayOf("--file=") // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.get("f"), null) - assertEquals(parser.get("file"), null) + assertEquals(arguments.option("f"), null) + assertEquals(arguments.option("file"), null) } @Test @@ -63,12 +63,12 @@ class ParserTest { val args = arrayOf("-q") // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.isSet("q"), true) - assertEquals(parser.isSet("quiet"), true) - assertEquals(parser.isSet("silent"), true) + assertEquals(arguments.flag("q"), true) + assertEquals(arguments.flag("quiet"), true) + assertEquals(arguments.flag("silent"), true) } @Test @@ -77,12 +77,12 @@ class ParserTest { val args = arrayOf("--quiet") // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.isSet("q"), true) - assertEquals(parser.isSet("quiet"), true) - assertEquals(parser.isSet("silent"), true) + assertEquals(arguments.flag("q"), true) + assertEquals(arguments.flag("quiet"), true) + assertEquals(arguments.flag("silent"), true) } @Test @@ -91,12 +91,12 @@ class ParserTest { val args = arrayOf("--quiet", "--silent=false") // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.isSet("q"), false) - assertEquals(parser.isSet("quiet"), false) - assertEquals(parser.isSet("silent"), false) + assertEquals(arguments.flag("q"), false) + assertEquals(arguments.flag("quiet"), false) + assertEquals(arguments.flag("silent"), false) } @Test @@ -106,16 +106,16 @@ class ParserTest { val args = arrayOf("-f", "asdf.txt", "--quiet=true", "--allowEmpty=false", "trailing", "arguments" ) // Act - parser.parse(args) + val arguments = parser.parse(args) // Assert - assertEquals(parser.get("f"), filename) - assertEquals(parser.get("file"), filename) - assertEquals(parser.isSet("q"), true) - assertEquals(parser.isSet("quiet"), true) - assertEquals(parser.isSet("silent"), true) - assertEquals(parser.isSet("a"), false) - assertEquals(parser.isSet("allowEmpty"), false) - assertEquals(parser.remainingArgs, listOf("trailing", "arguments")) + assertEquals(arguments.option("f"), filename) + assertEquals(arguments.option("file"), filename) + assertEquals(arguments.flag("q"), true) + assertEquals(arguments.flag("quiet"), true) + assertEquals(arguments.flag("silent"), true) + assertEquals(arguments.flag("a"), false) + assertEquals(arguments.flag("allowEmpty"), false) + assertEquals(arguments.unparsedArgs, listOf("trailing", "arguments")) } } \ No newline at end of file