diff --git a/CHANGELOG.md b/CHANGELOG.md index 46eb1f25..2ed1bc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Added more options to `CliktCommand.test` to control the terminal interactivity. ([#517](https://github.com/ajalt/clikt/pull/517)) - Added `associate{}`, `associateBy{}`, and `associateWith{}` transforms for options that allow you to convert the keys and values of the map. ([#529](https://github.com/ajalt/clikt/pull/529)) - Added support for aliasing options to other options. ([#535](https://github.com/ajalt/clikt/pull/535)) +- Added `limit` and `ignoreCase` parameters to `option().split()`. ([#541](https://github.com/ajalt/clikt/pull/541)) ### Changed - In a subcommand with and an `argument()` with `multiple()` or `optional()`, the behavior is now the same regardless of the value of `allowMultipleSubcommands`: if a token matches a subcommand name, it's now treated as a subcommand rather than a positional argument. diff --git a/clikt-mordant/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt b/clikt-mordant/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt index 45b5720d..fd675af9 100644 --- a/clikt-mordant/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt +++ b/clikt-mordant/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt @@ -256,6 +256,25 @@ class OptionTest { C().parse(argv) } + @Test + @JsName("two_options_with_split_and_limit") + fun `two options with split and limit`() = forAll( + row("", null, null), + row("-x 5 -y a", listOf("5"), listOf("a")), + row("-x 5x6X7x8 -y a:b:c", listOf("5", "6", "7x8"), listOf("a", "b:c")) + ) { argv, ex, ey -> + class C : TestCommand() { + val x by option("-x").split("x", ignoreCase = true, limit = 3) + val y by option("-y").split(Regex(":"), limit=2) + override fun run_() { + x shouldBe ex + y shouldBe ey + } + } + + C().parse(argv) + } + @Test @JsName("flag_options") fun `flag options`() = forAll( diff --git a/clikt/api/clikt.api b/clikt/api/clikt.api index 5e969665..feb8d996 100644 --- a/clikt/api/clikt.api +++ b/clikt/api/clikt.api @@ -1104,8 +1104,8 @@ public final class com/github/ajalt/clikt/parameters/options/OptionTransformCont } public abstract interface class com/github/ajalt/clikt/parameters/options/OptionWithValues : com/github/ajalt/clikt/parameters/options/OptionDelegate { - public abstract fun copy (Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/text/Regex;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZ)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; - public abstract fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/text/Regex;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZ)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public abstract fun copy (Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZ)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public abstract fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZ)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public abstract fun getEnvvar ()Ljava/lang/String; public abstract fun getExplicitCompletionCandidates ()Lcom/github/ajalt/clikt/completion/CompletionCandidates; public abstract fun getHelpGetter ()Lkotlin/jvm/functions/Function1; @@ -1114,12 +1114,12 @@ public abstract interface class com/github/ajalt/clikt/parameters/options/Option public abstract fun getTransformEach ()Lkotlin/jvm/functions/Function2; public abstract fun getTransformValidator ()Lkotlin/jvm/functions/Function2; public abstract fun getTransformValue ()Lkotlin/jvm/functions/Function2; - public abstract fun getValueSplit ()Lkotlin/text/Regex; + public abstract fun getValueSplit ()Lkotlin/jvm/functions/Function1; } public final class com/github/ajalt/clikt/parameters/options/OptionWithValues$DefaultImpls { - public static synthetic fun copy$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/text/Regex;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; - public static synthetic fun copy$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/text/Regex;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static synthetic fun copy$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static synthetic fun copy$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/ranges/IntRange;Lkotlin/jvm/functions/Function1;ZLjava/util/Map;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/github/ajalt/clikt/completion/CompletionCandidates;Ljava/util/Set;ZZZILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static fun getAcceptsNumberValueWithoutName (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Z public static fun getAcceptsUnattachedValue (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Z public static fun getCompletionCandidates (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Lcom/github/ajalt/clikt/completion/CompletionCandidates; @@ -1162,8 +1162,10 @@ public final class com/github/ajalt/clikt/parameters/options/OptionWithValuesKt public static synthetic fun optionalValueLazy$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static final fun pair (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static final fun required (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; - public static final fun split (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Ljava/lang/String;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; - public static final fun split (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/text/Regex;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static final fun split (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/text/Regex;I)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static final fun split (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;[Ljava/lang/String;ZI)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static synthetic fun split$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Lkotlin/text/Regex;IILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; + public static synthetic fun split$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;[Ljava/lang/String;ZIILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static final fun splitPair (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Ljava/lang/String;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static synthetic fun splitPair$default (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;Ljava/lang/String;ILjava/lang/Object;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; public static final fun toMap (Lcom/github/ajalt/clikt/parameters/options/OptionWithValues;)Lcom/github/ajalt/clikt/parameters/options/OptionWithValues; diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Convert.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Convert.kt index a385e864..dba9347e 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Convert.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Convert.kt @@ -88,7 +88,7 @@ inline fun NullableOption.convert( /** - * Change to option to take any number of values, separated by a [regex]. + * Change to option to take any number of values, separated by matched of a [regex]. * * This must be called after converting the value type, and before other transforms. * @@ -101,8 +101,11 @@ inline fun NullableOption.convert( * Which can be called like this: * * `./program --opt 1,2,3` + * + * @param limit Non-negative value specifying the maximum number of substrings to return. + * Zero by default means no limit is set. */ -fun NullableOption.split(regex: Regex) +fun NullableOption.split(regex: Regex, limit: Int = 0) : OptionWithValues?, List, ValueT> { return copy( transformValue = transformValue, @@ -110,12 +113,12 @@ fun NullableOption.split(regex: Regex) transformAll = defaultAllProcessor(), validator = defaultValidator(), nvalues = 1..1, - valueSplit = regex + valueSplit = { it.split(regex, limit) } ) } /** - * Change to option to take any number of values, separated by a string [delimiter]. + * Change to option to take any number of values, separated by the [delimiters]. * * This must be called after converting the value type, and before other transforms. * @@ -128,10 +131,24 @@ fun NullableOption.split(regex: Regex) * Which can be called like this: * * `./program --opt 1,2,3` + * + * @param delimiters One or more strings to be used as delimiters. + * @param ignoreCase `true` to ignore character case when matching a delimiter. By default `false`. + * @param limit The maximum number of substrings to return. Zero by default means no limit is set. */ -fun NullableOption.split(delimiter: String) - : OptionWithValues?, List, ValueT> { - return split(Regex.fromLiteral(delimiter)) +fun NullableOption.split( + vararg delimiters: String, + ignoreCase: Boolean = false, + limit: Int = 0, +): OptionWithValues?, List, ValueT> { + return copy( + transformValue = transformValue, + transformEach = { it }, + transformAll = defaultAllProcessor(), + validator = defaultValidator(), + nvalues = 1..1, + valueSplit = { it.split(*delimiters, ignoreCase = ignoreCase, limit = limit) } + ) } diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Option.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Option.kt index 82061702..bd341870 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Option.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/Option.kt @@ -140,20 +140,14 @@ internal fun splitOptionPrefix(name: String): Pair = @PublishedApi internal fun Option.longestName(): String? = names.maxByOrNull { it.length } -internal sealed class FinalValue { - data class Parsed(val values: List) : FinalValue() - data class Sourced(val values: List) : FinalValue() - data class Envvar(val key: String, val value: String) : FinalValue() -} - internal fun Option.getFinalValue( context: Context, invocations: List, envvar: String?, -): FinalValue { +): List { return when { // We don't look at envvars or the value source for eager options - eager || invocations.isNotEmpty() -> FinalValue.Parsed(invocations) + eager || invocations.isNotEmpty() -> invocations context.readEnvvarBeforeValueSource -> { readEnvVar(context, envvar) ?: readValueSource(context) } @@ -161,7 +155,7 @@ internal fun Option.getFinalValue( else -> { readValueSource(context) ?: readEnvVar(context, envvar) } - } ?: FinalValue.Parsed(emptyList()) + } ?: emptyList() } // This is a pretty ugly hack: option groups need to enforce their constraints, including on options @@ -172,17 +166,19 @@ internal fun Option.hasEnvvarOrSourcedValue( context: Context, invocations: List, ): Boolean { + if (invocations.isNotEmpty()) return false val envvar = (this as? OptionWithValues<*, *, *>)?.envvar val final = this.getFinalValue(context, invocations, envvar) - return final !is FinalValue.Parsed + return final.isNotEmpty() } -private fun Option.readValueSource(context: Context): FinalValue? { - return context.valueSource?.getValues(context, this)?.ifEmpty { null } - ?.let { FinalValue.Sourced(it) } +private fun Option.readValueSource(context: Context): List? { + return context.valueSource?.getValues(context, this) + ?.map { OptionInvocation("", it.values) } + ?.ifEmpty { null } } -private fun Option.readEnvVar(context: Context, envvar: String?): FinalValue? { +private fun Option.readEnvVar(context: Context, envvar: String?): List? { val env = inferEnvvar(names, envvar, context.autoEnvvarPrefix) ?: return null - return context.readEnvvar(env)?.let { FinalValue.Envvar(env, it) } + return context.readEnvvar(env)?.let { listOf(OptionInvocation(env, listOf(it))) } } diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/OptionWithValues.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/OptionWithValues.kt index 2bb24810..6e9ffe72 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/OptionWithValues.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/OptionWithValues.kt @@ -106,8 +106,8 @@ interface OptionWithValues : OptionDelegate { /** A block that will return the help text for this option, or `null` if no getter has been specified */ val helpGetter: (HelpTransformContext.() -> String)? - /** A regex to split option values on before conversion, or `null` to leave them unsplit */ - val valueSplit: Regex? + /** A function to split option values on before conversion */ + val valueSplit: (String) -> List /** Create a new option that is a copy of this one with different transforms. */ fun copy( @@ -123,7 +123,7 @@ interface OptionWithValues : OptionDelegate { helpTags: Map = this.helpTags, valueSourceKey: String? = this.valueSourceKey, envvar: String? = this.envvar, - valueSplit: Regex? = this.valueSplit, + valueSplit: (String) -> List = this.valueSplit, completionCandidates: CompletionCandidates? = explicitCompletionCandidates, secondaryNames: Set = this.secondaryNames, acceptsNumberValueWithoutName: Boolean = this.acceptsNumberValueWithoutName, @@ -142,7 +142,7 @@ interface OptionWithValues : OptionDelegate { helpTags: Map = this.helpTags, envvar: String? = this.envvar, valueSourceKey: String? = this.valueSourceKey, - valueSplit: Regex? = this.valueSplit, + valueSplit: (String) -> List = this.valueSplit, completionCandidates: CompletionCandidates? = explicitCompletionCandidates, secondaryNames: Set = this.secondaryNames, acceptsNumberValueWithoutName: Boolean = this.acceptsNumberValueWithoutName, @@ -161,7 +161,7 @@ private class OptionWithValuesImpl( override val helpTags: Map, override val valueSourceKey: String?, override val envvar: String?, - override val valueSplit: Regex?, + override val valueSplit: (String) -> List, override val explicitCompletionCandidates: CompletionCandidates?, override val secondaryNames: Set, override val acceptsNumberValueWithoutName: Boolean, @@ -188,31 +188,13 @@ private class OptionWithValuesImpl( } override fun finalize(context: Context, invocations: List) { - val invs = when (val v = getFinalValue(context, invocations, envvar)) { - is FinalValue.Parsed -> { - when (valueSplit) { - null -> { - invocations.find { it.values.size !in nvalues }?.let { - throw IncorrectOptionValueCount(this, it.name) - } - invocations - } - else -> invocations.map { inv -> - inv.copy(values = inv.values.flatMap { it.split(valueSplit) }) - } - } - } - - is FinalValue.Sourced -> { - v.values.map { OptionInvocation("", it.values) } - } - - is FinalValue.Envvar -> { - when (valueSplit) { - null -> listOf(OptionInvocation(v.key, listOf(v.value))) - else -> listOf(OptionInvocation(v.key, v.value.split(valueSplit))) - } + val invs = getFinalValue(context, invocations, envvar).map { inv -> + // Only enforce nvalues if there are command line invocations, since some options like + // switches work differently for envvars. + if (invocations.isNotEmpty() && inv.values.size !in nvalues) { + throw IncorrectOptionValueCount(this, inv.name) } + inv.copy(values = inv.values.flatMap { valueSplit(it) }) } value = transformAll(OptionTransformContext(this, context), invs.map { @@ -248,7 +230,7 @@ private class OptionWithValuesImpl( helpTags: Map, valueSourceKey: String?, envvar: String?, - valueSplit: Regex?, + valueSplit: (String) -> List, completionCandidates: CompletionCandidates?, secondaryNames: Set, acceptsNumberValueWithoutName: Boolean, @@ -288,7 +270,7 @@ private class OptionWithValuesImpl( helpTags: Map, envvar: String?, valueSourceKey: String?, - valueSplit: Regex?, + valueSplit: (String) -> List, completionCandidates: CompletionCandidates?, secondaryNames: Set, acceptsNumberValueWithoutName: Boolean, @@ -378,7 +360,7 @@ fun ParameterHolder.option( helpTags = helpTags, valueSourceKey = valueSourceKey, envvar = envvar, - valueSplit = null, + valueSplit = ::listOf, explicitCompletionCandidates = completionCandidates, secondaryNames = emptySet(), acceptsNumberValueWithoutName = false,