Skip to content

Commit ad02b84

Browse files
authored
Imrpove add more details to error reporting (#557)
- Report validation errors even if parsing fails - Add a hint for unknown options when the name matches a subcommand
1 parent 6812854 commit ad02b84

File tree

10 files changed

+74
-25
lines changed

10 files changed

+74
-25
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
- Added completion commands for suspending and chained commands. ([#553](https://github.com/ajalt/clikt/pull/553))
66
- Added no-op suspending commands. ([#554](https://github.com/ajalt/clikt/pull/554))
77

8+
### Changed
9+
- Unknown option errors and missing argument errors that occur at the same time will now both be reported. ([#553](https://github.com/ajalt/clikt/pull/553))
10+
811
## 5.0.0
912
### Added
1013
- Publish `iosArm64` and `iosX64` targets.

clikt/api/clikt.api

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,8 +412,10 @@ public final class com/github/ajalt/clikt/core/NoSuchArgument : com/github/ajalt
412412
}
413413

414414
public final class com/github/ajalt/clikt/core/NoSuchOption : com/github/ajalt/clikt/core/UsageError {
415+
public fun <init> (Ljava/lang/String;)V
415416
public fun <init> (Ljava/lang/String;Ljava/util/List;)V
416-
public synthetic fun <init> (Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
417+
public fun <init> (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)V
418+
public synthetic fun <init> (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
417419
public fun formatMessage (Lcom/github/ajalt/clikt/output/Localization;Lcom/github/ajalt/clikt/output/ParameterFormatter;)Ljava/lang/String;
418420
}
419421

@@ -686,6 +688,7 @@ public abstract interface class com/github/ajalt/clikt/output/Localization {
686688
public abstract fun missingOption (Ljava/lang/String;)Ljava/lang/String;
687689
public abstract fun mutexGroupException (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
688690
public abstract fun noSuchOption (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
691+
public abstract fun noSuchOptionWithSubCommandPossibility (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
689692
public abstract fun noSuchSubcommand (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
690693
public abstract fun optionsMetavar ()Ljava/lang/String;
691694
public abstract fun optionsTitle ()Ljava/lang/String;
@@ -745,6 +748,7 @@ public final class com/github/ajalt/clikt/output/Localization$DefaultImpls {
745748
public static fun missingOption (Lcom/github/ajalt/clikt/output/Localization;Ljava/lang/String;)Ljava/lang/String;
746749
public static fun mutexGroupException (Lcom/github/ajalt/clikt/output/Localization;Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
747750
public static fun noSuchOption (Lcom/github/ajalt/clikt/output/Localization;Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
751+
public static fun noSuchOptionWithSubCommandPossibility (Lcom/github/ajalt/clikt/output/Localization;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
748752
public static fun noSuchSubcommand (Lcom/github/ajalt/clikt/output/Localization;Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
749753
public static fun optionsMetavar (Lcom/github/ajalt/clikt/output/Localization;)Ljava/lang/String;
750754
public static fun optionsTitle (Lcom/github/ajalt/clikt/output/Localization;)Ljava/lang/String;

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/exceptions.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.github.ajalt.clikt.output.ParameterFormatter
55
import com.github.ajalt.clikt.parameters.arguments.Argument
66
import com.github.ajalt.clikt.parameters.options.Option
77
import com.github.ajalt.clikt.parameters.options.longestName
8+
import kotlin.jvm.JvmOverloads
89

910
/**
1011
* An exception during command line processing that should be shown to the user.
@@ -238,15 +239,21 @@ class NoSuchSubcommand(
238239
}
239240

240241
/** An option was provided that does not exist. */
241-
class NoSuchOption(
242+
class NoSuchOption @JvmOverloads constructor(
243+
// TODO (6.0): remove JvmOverloads
242244
paramName: String,
243245
private val possibilities: List<String> = emptyList(),
246+
private val subcommand: String? = null,
244247
) : UsageError(null, paramName) {
245248
override fun formatMessage(localization: Localization, formatter: ParameterFormatter): String {
246-
return localization.noSuchOption(
247-
paramName?.let(formatter::formatOption) ?: "",
248-
possibilities.map(formatter::formatOption)
249-
)
249+
val name = paramName?.let(formatter::formatOption) ?: ""
250+
return if (subcommand != null) {
251+
localization.noSuchOptionWithSubCommandPossibility(
252+
name, formatter.formatSubcommand(formatter.formatSubcommand(subcommand))
253+
)
254+
} else {
255+
localization.noSuchOption(name, possibilities.map(formatter::formatOption))
256+
}
250257
}
251258
}
252259

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/internal/Finalization.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ internal fun finalizeParameters(
3636
): List<UsageError> {
3737
// Add uninvoked params last so that e.g. we can skip prompting if there's an error in an
3838
// invoked option
39-
4039
val allGroups = buildMap<ParameterGroup?, Map<Option, List<OptionInvocation>>> {
4140
optionInvocations.entries
4241
.groupBy({ it.key.group }, { it.key to it.value })

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/output/Localization.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ interface Localization {
4848
}
4949
}
5050

51+
/**
52+
* Message for [NoSuchOption] when a subcommand has an option with the same name
53+
*/
54+
fun noSuchOptionWithSubCommandPossibility(name: String, subcommand: String): String {
55+
return "no such option $name. hint: $subcommand has an option $name"
56+
}
57+
5158
/**
5259
* Message for [IncorrectOptionValueCount]
5360
*

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/CommandLineParser.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ object CommandLineParser {
105105
fun <T : BaseCliktCommand<T>> parse(command: T, argv: List<String>): CommandLineParseResult<T> {
106106
return parseArgv(command, argv)
107107
}
108+
108109
/**
109110
* Finalize eager options for a command invocation, running them if they were invoked.
110111
*
@@ -143,15 +144,17 @@ object CommandLineParser {
143144
val (_, nonEagerOpts) = getOpts(command)
144145
val (_, nonEagerInvs) = getInvs(invocation)
145146

146-
// throw any parse errors after the eager options are finalized
147-
invocation.throwErrors()
147+
// Collect any usage errors to combine with errors from finalization
148+
val usageErrors = invocation.getUsageErrorsOrThrow()
148149

149150
// then finalize and validate everything else
150151
val nonEagerNonGroupOpts = nonEagerOpts.filter { it.group == null }
151152
val argumentInvocations = invocation.argumentInvocations
152-
finalizeParameters(
153+
val finalizationErrors = finalizeParameters(
153154
context, nonEagerNonGroupOpts, groups, arguments, nonEagerInvs, argumentInvocations
154-
).throwErrors()
155+
)
156+
// Throw any errors before validating parameters
157+
(usageErrors + finalizationErrors).throwErrors()
155158

156159
validateParameters(context, nonEagerNonGroupOpts, groups, arguments).throwErrors()
157160

@@ -175,12 +178,14 @@ object CommandLineParser {
175178
.partition { it.eager }
176179
}
177180

178-
private fun CommandInvocation<*>.throwErrors() {
179-
// The errors are always UsageErrors, expect for the case of printHelpOnEmptyArgs
180-
when (val first = errors.firstOrNull()) {
181-
is UsageError -> errors.filterIsInstance<UsageError>().throwErrors()
182-
is CliktError -> throw first
183-
}
181+
/**
182+
* Return any UsageErrors. If there's a non-UsageError, throw it.
183+
*/
184+
private fun CommandInvocation<*>.getUsageErrorsOrThrow(): List<UsageError> {
185+
val usageErrors = errors.filterIsInstance<UsageError>()
186+
if (usageErrors.size == errors.size) return usageErrors
187+
// `errors` are always UsageErrors, except for the case of printHelpOnEmptyArgs
188+
errors.first { it !is UsageError }.let { throw it }
184189
}
185190

186191
private fun throwCompletionMessageIfRequested(

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/ParserInternals.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ private class CommandParser<T : BaseCliktCommand<T>>(
232232
name,
233233
optionsByName.filterNot { it.value.hidden }.keys.toList()
234234
)
235-
return OptParseResult(1, err = NoSuchOption(name, possibilities))
235+
return OptParseResult(1, err = createNoSuchOption(name, possibilities))
236236
}
237237

238238
return parseOptValues(option, name, attachedValue)
@@ -258,7 +258,7 @@ private class CommandParser<T : BaseCliktCommand<T>>(
258258
prefix == "-" && "-$tok" in optionsByName -> listOf("-$tok")
259259
else -> emptyList()
260260
}
261-
return OptParseResult(1, err = NoSuchOption(name, possibilities))
261+
return OptParseResult(1, err = createNoSuchOption(name, possibilities))
262262
}
263263
if (option.nvalues.last > 0) {
264264
val value = if (i < tok.lastIndex) tok.drop(i + 1) else null
@@ -271,6 +271,14 @@ private class CommandParser<T : BaseCliktCommand<T>>(
271271
return OptParseResult(1, invocations)
272272
}
273273

274+
private fun createNoSuchOption(name: String, possibilities: List<String>): NoSuchOption {
275+
if (possibilities.isEmpty()) {
276+
val c = allSubcommands.values.find { it._options.any { o -> name in o.names } }
277+
if (c != null) return NoSuchOption(name, subcommand = c.commandName)
278+
}
279+
return NoSuchOption(name, possibilities)
280+
}
281+
274282
private fun parseOptValues(
275283
option: Option,
276284
name: String,

test/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/ArgumentTest.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,7 @@ class ArgumentTest {
476476
@Test
477477
@JsName("punctuation_in_arg_prefix_unix_style_error")
478478
fun `punctuation in arg prefix unix style error`() {
479-
class C : TestCommand(called = false) {
480-
val x by argument()
481-
}
479+
class C : TestCommand(called = false)
482480
shouldThrow<NoSuchOption> { C().parse("-foo") }
483481
}
484482

@@ -509,8 +507,6 @@ class ArgumentTest {
509507
init {
510508
context { helpOptionNames = setOf("/help") }
511509
}
512-
513-
val x by argument()
514510
}
515511
shouldThrow<NoSuchOption> { C().parse("/foo") }
516512
}

test/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/MultiUsageErrorTest.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ class MultiUsageErrorTest {
2929
),
3030
row("--x=0 --y=0 1", listOf("invalid value for A: 1")),
3131
row(
32+
// don't report unknown arg error after unknown opts
3233
"--y=0 --x=0 --n 1 2 3",
3334
listOf("no such option --n. (Possible options: --x, --y)")
34-
), // don't report arg error after unknown opts
35+
),
36+
row(
37+
// do report missing arg after unknown opts
38+
"--y=0 --x=0 --n",
39+
listOf("no such option --n. (Possible options: --x, --y)", "missing argument A")
40+
)
3541
) { argv, ex ->
3642
class C : TestCommand(called = false) {
3743
val x by option().int().required().check { it == 0 }

test/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ class OptionTest {
8888
c.getFormattedHelp(err) shouldContain "Error: custom message"
8989
}
9090

91+
@Test
92+
@JsName("no_such_option_subcommand_hint")
93+
fun `no such option subcommand hint`() {
94+
class C : TestCommand(called = false)
95+
class Sub: TestCommand(called = false) {
96+
val foo by option()
97+
}
98+
99+
val c = C().subcommands(Sub())
100+
shouldThrow<NoSuchOption> {
101+
c.parse("--foo")
102+
}.formattedMessage shouldBe "no such option --foo. hint: sub has an option --foo"
103+
}
104+
91105
@Test
92106
@JsName("one_option")
93107
fun `one option`() = forAll(

0 commit comments

Comments
 (0)