Skip to content

Commit

Permalink
Add ignoreCase parameter to choice and enum functions (#119)
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt authored Jan 24, 2020
1 parent 66cf4aa commit c3bbf9d
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 34 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
- `CompletionCandidates.Fixed` now has a secondary convenience constructor that take a `vararg` of `String`s
- `CompletionCadidates.Custom`, which allows you to call other binaries or write a script to generate completions.
- `Option.wrapValue` and `Argument.wrapValue` to make it easier to reuse existing conversion functions.
- `ignoreCase` parameter to `choice()` and `enum()` conversion functions.

### Changed
- `option()` and `argument()` now take optional `completionCandidates` parameters to override how completion is generated. The constructor and `copy` functions of `OptionsWithValues` and `ProcessedArgument` have changed to support default values.
- The overloads of `findObject` ([1](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.core/-context/find-object/) [2](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.core/find-object/)) that take a default value have been renamed `findOrSetObject`. The existing names are marked with `@Deprecated`, and IntelliJ can convert your callsites automatically.
- `enum()` parameters now accept case-insensitive values by default. You change this behavior by passing `ignoreCase = false` to `enum()`

### Fixed
- `groupChoice` help output now includes the choices in the help output metavar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,48 +21,58 @@ private fun errorMessage(choice: String, choices: Map<String, *>): String {
/**
* Convert the argument based on a fixed set of values.
*
* If [ignoreCase] is `true`, the argument will accept values is any mix of upper and lower case.
*
* ### Example:
*
* ```kotlin
* argument().choice(mapOf("foo" to 1, "bar" to 2))
* ```
*/
fun <T : Any> RawArgument.choice(choices: Map<String, T>): ProcessedArgument<T, T> {
fun <T : Any> RawArgument.choice(choices: Map<String, T>, ignoreCase: Boolean = false): ProcessedArgument<T, T> {
require(choices.isNotEmpty()) { "Must specify at least one choice" }
val c = if (ignoreCase) choices.mapKeys { it.key.toLowerCase() } else choices
return convert(completionCandidates = Fixed(choices.keys)) {
choices[it] ?: fail(errorMessage(it, choices))
c[if (ignoreCase) it.toLowerCase() else it] ?: fail(errorMessage(it, choices))
}
}

/**
* Convert the argument based on a fixed set of values.
*
* If [ignoreCase] is `true`, the argument will accept values is any mix of upper and lower case.
*
* ### Example:
*
* ```kotlin
* argument().choice("foo" to 1, "bar" to 2)
* ```
*/
fun <T : Any> RawArgument.choice(vararg choices: Pair<String, T>): ProcessedArgument<T, T> {
return choice(choices.toMap())
fun <T : Any> RawArgument.choice(vararg choices: Pair<String, T>, ignoreCase: Boolean = false): ProcessedArgument<T, T> {
return choice(choices.toMap(), ignoreCase)
}

/**
* Restrict the argument to a fixed set of values.
*
* If [ignoreCase] is `true`, the argument will accept values is any mix of upper and lower case.
* The argument's final value will always match the case of the corresponding value in [choices].
*
* ### Example:
*
* ```kotlin
* argument().choice("foo", "bar")
* ```
*/
fun RawArgument.choice(vararg choices: String): ProcessedArgument<String, String> {
return choice(choices.associateBy { it })
fun RawArgument.choice(vararg choices: String, ignoreCase: Boolean = false): ProcessedArgument<String, String> {
return choice(choices.associateBy { it }, ignoreCase)
}

/**
* Convert the argument to the values of an enum.
*
* If [ignoreCase] is `false`, the argument will only accept values that match the case of the enum values.
*
* ### Example:
*
* ```kotlin
Expand All @@ -73,15 +83,20 @@ fun RawArgument.choice(vararg choices: String): ProcessedArgument<String, String
* @param key A block that returns the command line value to use for an enum value. The default is
* the enum name.
*/
inline fun <reified T : Enum<T>> RawArgument.enum(key: (T) -> String = { it.name }): ProcessedArgument<T, T> {
return choice(enumValues<T>().associateBy { key(it) })
inline fun <reified T : Enum<T>> RawArgument.enum(
ignoreCase: Boolean = true,
key: (T) -> String = { it.name }
): ProcessedArgument<T, T> {
return choice(enumValues<T>().associateBy { key(it) }, ignoreCase)
}

// options

/**
* Convert the option based on a fixed set of values.
*
* If [ignoreCase] is `true`, the option will accept values is any mix of upper and lower case.
*
* ### Example:
*
* ```kotlin
Expand All @@ -90,17 +105,23 @@ inline fun <reified T : Enum<T>> RawArgument.enum(key: (T) -> String = { it.name
*
* @see com.github.ajalt.clikt.parameters.groups.groupChoice
*/
fun <T : Any> RawOption.choice(choices: Map<String, T>,
metavar: String = mvar(choices.keys)): NullableOption<T, T> {
fun <T : Any> RawOption.choice(
choices: Map<String, T>,
metavar: String = mvar(choices.keys),
ignoreCase: Boolean = false
): NullableOption<T, T> {
require(choices.isNotEmpty()) { "Must specify at least one choice" }
val c = if (ignoreCase) choices.mapKeys { it.key.toLowerCase() } else choices
return convert(metavar, completionCandidates = Fixed(choices.keys)) {
choices[it] ?: fail(errorMessage(it, choices))
c[if (ignoreCase) it.toLowerCase() else it] ?: fail(errorMessage(it, choices))
}
}

/**
* Convert the option based on a fixed set of values.
*
* If [ignoreCase] is `true`, the option will accept values is any mix of upper and lower case.
*
* ### Example:
*
* ```kotlin
Expand All @@ -109,28 +130,39 @@ fun <T : Any> RawOption.choice(choices: Map<String, T>,
*
* @see com.github.ajalt.clikt.parameters.groups.groupChoice
*/
fun <T : Any> RawOption.choice(vararg choices: Pair<String, T>,
metavar: String = mvar(choices.map { it.first })): NullableOption<T, T> {
return choice(choices.toMap(), metavar)
fun <T : Any> RawOption.choice(
vararg choices: Pair<String, T>,
metavar: String = mvar(choices.map { it.first }),
ignoreCase: Boolean = false
): NullableOption<T, T> {
return choice(choices.toMap(), metavar, ignoreCase)
}

/**
* Restrict the option to a fixed set of values.
*
* If [ignoreCase] is `true`, the option will accept values is any mix of upper and lower case.
* The option's final value will always match the case of the corresponding value in [choices].
*
* ### Example:
*
* ```kotlin
* option().choice("foo", "bar")
* ```
*/
fun RawOption.choice(vararg choices: String,
metavar: String = mvar(choices.asIterable())): NullableOption<String, String> {
return choice(choices.associateBy { it }, metavar)
fun RawOption.choice(
vararg choices: String,
metavar: String = mvar(choices.asIterable()),
ignoreCase: Boolean = false
): NullableOption<String, String> {
return choice(choices.associateBy { it }, metavar, ignoreCase)
}

/**
* Convert the option to the values of an enum.
*
* If [ignoreCase] is `false`, the option will only accept values that match the case of the enum values.
*
* ### Example:
*
* ```kotlin
Expand All @@ -141,6 +173,9 @@ fun RawOption.choice(vararg choices: String,
* @param key A block that returns the command line value to use for an enum value. The default is
* the enum name.
*/
inline fun <reified T : Enum<T>> RawOption.enum(key: (T) -> String = { it.name }): NullableOption<T, T> {
return choice(enumValues<T>().associateBy { key(it) })
inline fun <reified T : Enum<T>> RawOption.enum(
ignoreCase: Boolean = true,
key: (T) -> String = { it.name }
): NullableOption<T, T> {
return choice(enumValues<T>().associateBy { key(it) }, ignoreCase = ignoreCase)
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class ChoiceTypeTest {

shouldThrow<BadParameterValue> { C().parse("--xx baz") }
.message shouldBe "Invalid value for \"--xx\": invalid choice: baz. (choose from foo, bar)"

shouldThrow<BadParameterValue> { C().parse("--xx FOO") }
.message shouldBe "Invalid value for \"--xx\": invalid choice: FOO. (choose from foo, bar)"
}

@Test
Expand All @@ -58,6 +61,32 @@ class ChoiceTypeTest {

shouldThrow<BadParameterValue> { C().parse("--xx=baz") }
.message shouldBe "Invalid value for \"--xx\": invalid choice: baz. (choose from foo, bar)"

shouldThrow<BadParameterValue> { C().parse("-x FOO") }
.message shouldBe "Invalid value for \"-x\": invalid choice: FOO. (choose from foo, bar)"
}

@Test
fun `choice option insensitive`() {
class C : TestCommand() {
val x by option("-x").choice("foo", "bar", ignoreCase = true)
val y by option("-y").choice("foo" to 1, "bar" to 2, ignoreCase = true)
}

C().apply {
parse("-xFOO -yFOO")
x shouldBe "foo"
y shouldBe 1
}

C().apply {
parse("-xbar -ybAR")
x shouldBe "bar"
y shouldBe 2
}

shouldThrow<BadParameterValue> { C().parse("-xbaz") }
.message shouldBe "Invalid value for \"-x\": invalid choice: baz. (choose from foo, bar)"
}

@Test
Expand All @@ -81,6 +110,9 @@ class ChoiceTypeTest {

shouldThrow<BadParameterValue> { C().parse("baz") }
.message shouldBe "Invalid value for \"X\": invalid choice: baz. (choose from foo, bar)"

shouldThrow<BadParameterValue> { C().parse("FOO") }
.message shouldBe "Invalid value for \"X\": invalid choice: FOO. (choose from foo, bar)"
}

@Test
Expand All @@ -104,15 +136,42 @@ class ChoiceTypeTest {

shouldThrow<BadParameterValue> { C().parse("baz") }
.message shouldBe "Invalid value for \"X\": invalid choice: baz. (choose from foo, bar)"

shouldThrow<BadParameterValue> { C().parse("FOO") }
.message shouldBe "Invalid value for \"X\": invalid choice: FOO. (choose from foo, bar)"
}

@Test
fun `choice argument insensitive`() {
class C : TestCommand() {
val x by argument().choice("foo", "bar", ignoreCase = true)
val y by argument().choice("foo" to 1, "bar" to 2, ignoreCase = true)
}

C().apply {
parse("FOO FOO")
x shouldBe "foo"
y shouldBe 1
}

C().apply {
parse("bar bAR")
x shouldBe "bar"
y shouldBe 2
}

shouldThrow<BadParameterValue> { C().parse("baz qux") }
.message shouldBe "Invalid value for \"X\": invalid choice: baz. (choose from foo, bar)"
}

@Test
fun `enum option`() = forall(
row("", null),
row("--xx A", TestEnum.A),
row("--xx a", TestEnum.A),
row("--xx=A", TestEnum.A),
row("-xB", TestEnum.B)
row("-xB", TestEnum.B),
row("-xb", TestEnum.B)
) { argv, expected ->
class C : TestCommand() {
val x by option("-x", "--xx").enum<TestEnum>()
Expand All @@ -127,11 +186,13 @@ class ChoiceTypeTest {
@Test
fun `enum option key`() = forall(
row("", null),
row("-xa", TestEnum.A),
row("-xb", TestEnum.B)
row("-xAz", TestEnum.A),
row("-xaZ", TestEnum.A),
row("-xBz", TestEnum.B),
row("-xBZ", TestEnum.B)
) { argv, expected ->
class C : TestCommand() {
val x by option("-x").enum<TestEnum> { it.name.toLowerCase() }
val x by option("-x").enum<TestEnum> { it.name + "z" }
override fun run_() {
x shouldBe expected
}
Expand All @@ -144,11 +205,14 @@ class ChoiceTypeTest {
fun `enum option error`() {
@Suppress("unused")
class C : TestCommand() {
val foo by option().enum<TestEnum>()
val foo by option().enum<TestEnum>(ignoreCase = false)
}

shouldThrow<BadParameterValue> { C().parse("--foo bar") }
.message shouldBe "Invalid value for \"--foo\": invalid choice: bar. (choose from A, B)"

shouldThrow<BadParameterValue> { C().parse("--foo a") }
.message shouldBe "Invalid value for \"--foo\": invalid choice: a. (choose from A, B)"
}

@Test
Expand All @@ -171,7 +235,8 @@ class ChoiceTypeTest {
fun `enum argument`() = forall(
row("", null, emptyList()),
row("A", TestEnum.A, emptyList()),
row("A A B", TestEnum.A, listOf(TestEnum.A, TestEnum.B))
row("b", TestEnum.B, emptyList()),
row("A a B", TestEnum.A, listOf(TestEnum.A, TestEnum.B))
) { argv, ex, ey ->
class C : TestCommand() {
val x by argument().enum<TestEnum>().optional()
Expand All @@ -188,16 +253,31 @@ class ChoiceTypeTest {
@Test
fun `enum argument key`() = forall(
row("", emptyList()),
row("a", listOf(TestEnum.A)),
row("a b", listOf(TestEnum.A, TestEnum.B))
row("az", listOf(TestEnum.A)),
row("AZ", listOf(TestEnum.A)),
row("aZ Bz", listOf(TestEnum.A, TestEnum.B))
) { argv, ex ->
class C : TestCommand() {
val x by argument().enum<TestEnum> { it.name.toLowerCase() }.multiple()
val x by argument().enum<TestEnum> { it.name + "z"}.multiple()
override fun run_() {
x shouldBe ex
}
}

C().parse(argv)
}

@Test
fun `enum argument error`() {
@Suppress("unused")
class C : TestCommand() {
val foo by argument().enum<TestEnum>(ignoreCase = false)
}

shouldThrow<BadParameterValue> { C().parse("bar") }
.message shouldBe "Invalid value for \"FOO\": invalid choice: bar. (choose from A, B)"

shouldThrow<BadParameterValue> { C().parse("a") }
.message shouldBe "Invalid value for \"FOO\": invalid choice: a. (choose from A, B)"
}
}
20 changes: 14 additions & 6 deletions docs/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,20 @@ You can restrict the values to a set of values, and optionally map the
input to a new value. For example, to create an option that only
accepts the value "A" or "B":

```kotlin
val opt: String? by option().choice("a", "b")
```
```kotlin
val opt: String? by option().choice("a", "b")
```

You can also convert the restricted set of values to a new type:

```kotlin
val color: Int by argument().choice("red" to 1, "green" to 2)
```
```kotlin
val color: Int by argument().choice("red" to 1, "green" to 2)
```

Choice parameters accept values that are case-sensitive by default. This can be configured by
passing `ignoreCase = true`.


* `Enum`: [`option().enum()` and `argument().enum()`][enum]

Like `choice`, but uses the values of an enum type.
Expand All @@ -109,6 +114,9 @@ enum class Color { RED, GREEN }
val color: Color by option().enum<Color>()
```

Enum parameters accept case-insensitive values by default. This can be configured by passing
`ignoreCase = false`.

* `File`: [`option().file()` and `argument().file()`][file]
* `Path`: [`option().path()` and `argument().path()`][path]

Expand Down

0 comments on commit c3bbf9d

Please sign in to comment.