Skip to content

Commit

Permalink
Add treatUnknownOptionsAsArgs (#173)
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt committed May 5, 2020
1 parent a0c33ab commit b9c1891
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
- Ability to use custom program exit status codes via `ProgramResult`.
- `inputStream` and `outputStream` conversions for options and arguments. ([#157](https://github.com/ajalt/clikt/issues/157) and [#158](https://github.com/ajalt/clikt/issues/158))
- `splitPair`, `toMap`, and `associate` extensions on `option`. ([#166](https://github.com/ajalt/clikt/issues/166))
- `treatUnknownOptionsAsArgs` parameter to `CliktCommand`. ([#152](https://github.com/ajalt/clikt/issues/166))

### Changed
- Update Kotlin to 1.3.71
Expand Down
Expand Up @@ -38,6 +38,11 @@ import com.github.ajalt.clikt.parsers.Parser
* @param allowMultipleSubcommands If true, allow multiple of this command's subcommands to be
* called sequentially. This will disable `allowInterspersedArgs` on the context of this command an
* its descendants. This functionality is experimental, and may change in a future release.
* @param treatUnknownOptionsAsArgs If true, any options on the command line whose names aren't
* valid will be parsed as an argument rather than reporting an error. You'll need to define an
* `argument().multiple()` to collect these options, or an error will still be reported. Unknown
* short option flags grouped with other flags on the command line will always be reported as
* errors.
*/
@Suppress("PropertyName")
@ParameterHolderDsl
Expand All @@ -49,7 +54,8 @@ abstract class CliktCommand(
val printHelpOnEmptyArgs: Boolean = false,
val helpTags: Map<String, String> = emptyMap(),
private val autoCompleteEnvvar: String? = "",
internal val allowMultipleSubcommands: Boolean = false
internal val allowMultipleSubcommands: Boolean = false,
internal val treatUnknownOptionsAsArgs: Boolean = false
) : ParameterHolder {
val commandName = name ?: inferCommandName()
val commandHelp = help
Expand Down
Expand Up @@ -7,7 +7,9 @@ import com.github.ajalt.clikt.parameters.options.EagerOption
import com.github.ajalt.clikt.parameters.options.Option
import com.github.ajalt.clikt.parameters.options.splitOptionPrefix
import com.github.ajalt.clikt.parsers.OptionParser.Invocation
import com.github.ajalt.clikt.parsers.OptionParser.ParseResult

private data class OptInvocation(val opt: Option, val inv: Invocation)
private data class OptParseResult(val consumed: Int, val unknown: List<String>, val known: List<OptInvocation>)

internal object Parser {
fun parse(argv: List<String>, context: Context) {
Expand Down Expand Up @@ -43,10 +45,10 @@ internal object Parser {
var subcommand: CliktCommand? = null
var canParseOptions = true
var canExpandAtFiles = context.expandArgumentFiles
val invocations = mutableListOf<Pair<Option, Invocation>>()
val invocations = mutableListOf<OptInvocation>()
var minAliasI = 0

fun isLongOptionWithEquals(prefix: String, token: String) : Boolean {
fun isLongOptionWithEquals(prefix: String, token: String): Boolean {
if ("=" !in token) return false
if (prefix.isEmpty()) return false
if (prefix.length > 1) return true
Expand All @@ -55,6 +57,12 @@ internal object Parser {
return true
}

fun consumeParse(result: OptParseResult) {
positionalArgs += result.unknown
invocations += result.known
i += result.consumed
}

loop@ while (i <= tokens.lastIndex) {
val tok = tokens[i]
val normTok = context.tokenTransformer(context, tok)
Expand All @@ -76,14 +84,10 @@ internal object Parser {
canExpandAtFiles = false
}
canParseOptions && (prefix.length > 1 && prefix in prefixes || normTok in longNames || isLongOptionWithEquals(prefix, tok)) -> {
val (opt, result) = parseLongOpt(context, tokens, tok, i, optionsByName)
invocations += opt to result.invocation
i += result.consumedCount
consumeParse(parseLongOpt(command.treatUnknownOptionsAsArgs, context, tokens, tok, i, optionsByName))
}
canParseOptions && tok.length >= 2 && prefix.isNotEmpty() && prefix in prefixes -> {
val (count, invokes) = parseShortOpt(context, tokens, tok, i, optionsByName)
invocations += invokes
i += count
consumeParse(parseShortOpt(command.treatUnknownOptionsAsArgs, context, tokens, tok, i, optionsByName))
}
i >= minAliasI && tok in aliases -> {
tokens = aliases.getValue(tok) + tokens.slice(i + 1..tokens.lastIndex)
Expand All @@ -102,9 +106,9 @@ internal object Parser {
}
}

val invocationsByOption = invocations.groupBy({ it.first }, { it.second })
val invocationsByGroup = invocations.groupBy { (it.first as? GroupableOption)?.parameterGroup }
val invocationsByOptionByGroup = invocationsByGroup.mapValues { (_, invs) -> invs.groupBy({ it.first }, { it.second }).filterKeys { it !is EagerOption } }
val invocationsByOption = invocations.groupBy({ it.opt }, { it.inv })
val invocationsByGroup = invocations.groupBy { (it.opt as? GroupableOption)?.parameterGroup }
val invocationsByOptionByGroup = invocationsByGroup.mapValues { (_, invs) -> invs.groupBy({ it.opt }, { it.inv }).filterKeys { it !is EagerOption } }

try {
// Finalize eager options
Expand All @@ -120,7 +124,7 @@ internal object Parser {
}
}

// Finalize option groups after other options so that the groups can their values
// Finalize option groups after other options so that the groups can use their values
invocationsByOptionByGroup.forEach { (group, invocations) ->
group?.finalize(context, invocations)
}
Expand Down Expand Up @@ -180,44 +184,55 @@ internal object Parser {
}

private fun parseLongOpt(
ignoreUnknown: Boolean,
context: Context,
tokens: List<String>,
tok: String,
index: Int,
optionsByName: Map<String, Option>
): Pair<Option, ParseResult> {
): OptParseResult {
val equalsIndex = tok.indexOf('=')
var (name, value) = if (equalsIndex >= 0) {
tok.substring(0, equalsIndex) to tok.substring(equalsIndex + 1)
} else {
tok to null
}
name = context.tokenTransformer(context, name)
val option = optionsByName[name] ?: throw NoSuchOption(
givenName = name,
possibilities = context.correctionSuggestor(name, optionsByName.keys.toList())
)
val option = optionsByName[name] ?: if (ignoreUnknown) {
return OptParseResult(1, listOf(tok), emptyList())
} else {
throw NoSuchOption(
givenName = name,
possibilities = context.correctionSuggestor(name, optionsByName.keys.toList())
)
}

val result = option.parser.parseLongOpt(option, name, tokens, index, value)
return option to result
return OptParseResult(result.consumedCount, emptyList(), listOf(OptInvocation(option, result.invocation)))
}

private fun parseShortOpt(
ignoreUnknown: Boolean,
context: Context,
tokens: List<String>,
tok: String,
index: Int,
optionsByName: Map<String, Option>
): Pair<Int, List<Pair<Option, Invocation>>> {
): OptParseResult {
val prefix = tok[0].toString()
val invocations = mutableListOf<Pair<Option, Invocation>>()
val invocations = mutableListOf<OptInvocation>()
for ((i, opt) in tok.withIndex()) {
if (i == 0) continue // skip the dash

val name = context.tokenTransformer(context, prefix + opt)
val option = optionsByName[name] ?: throw NoSuchOption(name)
val option = optionsByName[name] ?: if (ignoreUnknown && tok.length == 2) {
return OptParseResult(1, listOf(tok), emptyList())
} else {
throw NoSuchOption(name)
}
val result = option.parser.parseShortOpt(option, name, tokens, index, i)
invocations += option to result.invocation
if (result.consumedCount > 0) return result.consumedCount to invocations
invocations += OptInvocation(option, result.invocation)
if (result.consumedCount > 0) return OptParseResult(result.consumedCount, emptyList(), invocations)
}
throw IllegalStateException(
"Error parsing short option ${tokens[index]}: no parser consumed value.")
Expand Down
Expand Up @@ -316,6 +316,39 @@ class CliktCommandTest {
c.parse("")
}.message shouldContain "allowMultipleSubcommands"
}

@Test
@JsName("treat_unknown_options_as_arguments")
fun `treat unknown options as arguments`() {
class C : TestCommand(treatUnknownOptionsAsArgs = true) {
val foo by option().flag()
val args by argument().multiple()

override fun run_() {
foo shouldBe true
args shouldBe listOf("--bar", "baz", "--qux=qoz")
}
}

C().parse("--bar --foo baz --qux=qoz")
}

@Test
@JsName("treat_unknown_options_as_arguments_with_grouped_flag")
fun `treat unknown options as arguments with grouped flag`() {
class C(called:Boolean) : TestCommand(called=called, treatUnknownOptionsAsArgs = true) {
val foo by option("-f").flag()
val args by argument().multiple()
}

val c = C(true)
c.parse("-f -g -i")
c.foo shouldBe true
c.args shouldBe listOf("-g", "-i")
shouldThrow<NoSuchOption> {
C(false).parse("-fgi")
}.message shouldBe "no such option: \"-g\"."
}
}

private class MultiSub1(count: Int) : TestCommand(name = "foo", count = count) {
Expand Down
Expand Up @@ -15,7 +15,8 @@ open class TestCommand(
printHelpOnEmptyArgs: Boolean = false,
helpTags: Map<String, String> = emptyMap(),
autoCompleteEnvvar: String? = "",
allowMultipleSubcommands: Boolean = false
allowMultipleSubcommands: Boolean = false,
treatUnknownOptionsAsArgs: Boolean = false
) : CliktCommand(
help,
epilog,
Expand All @@ -24,7 +25,8 @@ open class TestCommand(
printHelpOnEmptyArgs,
helpTags,
autoCompleteEnvvar,
allowMultipleSubcommands
allowMultipleSubcommands,
treatUnknownOptionsAsArgs
) {
private var actualCount = 0
final override fun run() {
Expand Down
43 changes: 43 additions & 0 deletions docs/options.md
Expand Up @@ -744,6 +744,46 @@ Options:
--opt4 TEXT option 4 (deprecated)
```

## Unknown Options

You may want to collect unknown options for manual processing. You can do this by passing
`treatUnknownOptionsAsArgs = true` to your [`CliktCommand` constructor][CliktCommand.init]. This
will cause Clikt to treat unknown options as positional arguments rather than reporting an error
when one is encountered. You'll need to define an [`argument().multiple()`][argument.multiple]
property to collect the options, otherwise an error will still be reported.

```kotlin tab="Example"
class Wrapper : CliktCommand(treatUnknownOptionsAsArgs = true) {
init { context { allowInterspersedArgs = false } }

val command by option(help = "?").required()
val arguments by argument().multiple()

override fun run() {
val cmd = (listOf(command) + arguments).joinToString(" ")
val proc = Runtime.getRuntime().exec(cmd)
println(proc.inputStream.bufferedReader().readText())
proc.waitFor()
}
}
```

```text tab="Usage"
$ ./wrapper --command=git tag --help | head -n4
GIT-TAG(1) Git Manual GIT-TAG(1)
NAME
git-tag - Create, list, delete or verify a tag object signed with GPG
```

Note that flag options in a single token (e.g. using `-abc` to specify `-a`, `-b`, and `-c` in a
single token) will still report an error if they are unknown. Each option should be specified
separately in this mode.

You'll often want to set [`allowInterspersedArgs = false`][allowInterspersedArgs] on your Context when
using `treatUnknownOptionsAsArgs`. You may also find that subcommands are a better fit than
`treatUnknownOptionsAsArgs` for your use case.

## Values From Environment Variables

Clikt supports reading option values from environment variables if they
Expand Down Expand Up @@ -997,3 +1037,6 @@ val opt: Pair<Int, Int> by option("-o", "--opt")
[readEnvvarFirst]: api/clikt/com.github.ajalt.clikt.core/-context/-builder/read-envvar-before-value-source.md
[PropertiesValueSource]: api/clikt/com.github.ajalt.clikt.sources/-properties-value-source/index.md
[MapValueSource]: api/clikt/com.github.ajalt.clikt.sources/-map-value-source/index.md
[CliktCommand.init]: api/clikt/com.github.ajalt.clikt.core/-clikt-command/-init-/
[argument.multiple]: api/clikt/com.github.ajalt.clikt.parameters.arguments/multiple/
[allowInterspersedArgs]: api/clikt/com.github.ajalt.clikt.core/-context/allow-interspersed-args.md
3 changes: 2 additions & 1 deletion mkdocs.yml
Expand Up @@ -43,7 +43,7 @@ nav:
- 'Adding Parameters': quickstart/#adding-parameters
- 'Developing Command Line Applications With Gradle': quickstart/#developing-command-line-applications-with-gradle
- 'Why Clikt?':
- 'Why not a Kotlin library like kotlin-argparse or kotlinx.cli?': whyclikt/
- 'Why not a Kotlin library like kotlin-argparser or kotlinx.cli?': whyclikt/
- 'Why not a Java library like JCommander?': whyclikt/#why-not-a-java-library-like-jcommander
- 'Parameters':
- 'Differences': parameters/
Expand All @@ -70,6 +70,7 @@ nav:
- 'Password Prompts': options/#password-prompts
- 'Eager Options': options/#eager-options
- 'Deprecating Options': options/#deprecating-options
- 'Unknown Options': options/#unknown-options
- 'Values From Environment Variables': options/#values-from-environment-variables
- 'Values from Configuration Files': options/#values-from-configuration-files
- 'Windows and Java-Style Option Prefixes': options/#windows-and-java-style-option-prefixes
Expand Down

0 comments on commit b9c1891

Please sign in to comment.