From 250e9d4e0ab0cd74cbb5c07df6ab4cd10275bbef Mon Sep 17 00:00:00 2001 From: Infendro Date: Fri, 9 Jan 2026 23:28:02 +0100 Subject: [PATCH] implement suggestions for unknown command/option --- .../cli/command/context/ContextParser.kt | 8 ++-- .../cli/error/run/InvalidArgumentCount.kt | 2 +- .../cli/error/run/InvalidOptionCount.kt | 2 +- .../infendro/cli/error/run/InvalidValue.kt | 2 +- .../infendro/cli/error/run/MalformedOption.kt | 2 +- .../cli/error/run/MissingOptionValue.kt | 2 +- .../cli/error/run/UnexpectedArgument.kt | 6 ++- .../infendro/cli/error/run/UnknownCommand.kt | 20 +++++++- .../infendro/cli/error/run/UnknownOption.kt | 20 +++++++- .../kotlin/com/infendro/cli/util/Distance.kt | 48 +++++++++++++++++++ 10 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/util/Distance.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index b652c13..9cdde49 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -53,7 +53,7 @@ internal class ContextParser( } if (cmd == null) { when { - command.arguments.isEmpty() -> return failure(path, UnknownCommand(current)) + command.arguments.isEmpty() -> return failure(path, UnknownCommand(command, current)) else -> break } } @@ -87,9 +87,9 @@ internal class ContextParser( // argument endOfOptions || dashes == 0 -> { val argument = command.arguments.getOrNull(argumentIndex) - ?: return failure(path, UnexpectedArgument()) + ?: return failure(path, UnexpectedArgument(current)) - val value = when (val result = argument.parser.parse(trimmed)) { + val value = when (val result = argument.parser.parse(current)) { is Parser.Result.Success<*> -> result.value is Parser.Result.Failure<*> -> return failure(path, result.failure) } @@ -135,7 +135,7 @@ internal class ContextParser( for (name in names) { val option = command.options.firstOrNull { name in it.names } - ?: return failure(path, UnknownOption(name)) + ?: return failure(path, UnknownOption(command, name)) val value = when { text != null -> diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt index a03a585..6ac72f7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt @@ -13,6 +13,6 @@ class InvalidArgumentCount( argument.unbounded -> "at least ${argument.min}" else -> "${argument.min} to ${argument.max}" } - return """invalid number of values for argument "${argument.name}": expected $expected but found $count""" + return """Invalid number of values for argument "${argument.name}". Expected $expected but found $count.""" } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt index 49075af..848cdb8 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt @@ -13,6 +13,6 @@ class InvalidOptionCount( option.unbounded -> "at least ${option.min}" else -> "${option.min} to ${option.max}" } - return """invalid number of values passed for option "${option.name}": expected $expected but found $count""" + return """Invalid number of values passed for option "${option.name}". Expected $expected but found $count.""" } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt index dfc2562..ecf5d99 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt @@ -7,5 +7,5 @@ class InvalidValue( val type: KClass<*>, ) : RuntimeFailure() { override val message: String - get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" + get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}.""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt index a79299a..097db35 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt @@ -4,5 +4,5 @@ class MalformedOption( val option: String, ) : RuntimeFailure() { override val message: String - get() = """malformed option "$option"""" + get() = """Malformed option "$option".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt index 030b42d..49b1013 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt @@ -6,5 +6,5 @@ class MissingOptionValue( val option: Option<*>, ) : RuntimeFailure() { override val message: String - get() = """no value provided for option "${option.name}"""" + get() = """No value provided for option "${option.name}".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt index 121ae15..960d2d2 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt @@ -1,6 +1,8 @@ package com.infendro.cli.error.run -class UnexpectedArgument : RuntimeFailure() { +class UnexpectedArgument( + val argument: String, +) : RuntimeFailure() { override val message: String - get() = """unexpected argument""" + get() = """Unexpected argument "$argument".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt index e8f2730..643c51c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt @@ -1,8 +1,24 @@ package com.infendro.cli.error.run +import com.infendro.cli.command.Command +import com.infendro.cli.util.distance +import com.infendro.cli.util.threshold +import kotlin.math.min + class UnknownCommand( - val command: String, + val command: Command, + val name: String, ) : RuntimeFailure() { override val message: String - get() = """unknown command "$command"""" + get() = buildString { + val suggestion = command.commands + .map { it.name } + .associateWith { distance(name, it) } + .filter { it.value <= min(it.key.length - 1, threshold) } + .minByOrNull { it.value } + ?.key + + append("""Unknown command "$name".""") + if (suggestion != null) append(""" Did you mean "$suggestion"?""") + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt index da3e258..cb01a69 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt @@ -1,8 +1,24 @@ package com.infendro.cli.error.run +import com.infendro.cli.command.Command +import com.infendro.cli.util.distance +import com.infendro.cli.util.threshold +import kotlin.math.min + class UnknownOption( - val option: String, + val command: Command, + val name: String, ) : RuntimeFailure() { override val message: String - get() = """unknown option "$option"""" + get() = buildString { + val suggestion = command.options + .flatMap { it.names } + .associateWith { distance(name, it) } + .filter { it.value <= min(it.key.length - 1, threshold) } + .minByOrNull { it.value } + ?.key + + append("""Unknown option "$name".""") + if (suggestion != null) append(""" Did you mean "$suggestion"?""") + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt b/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt new file mode 100644 index 0000000..3272686 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt @@ -0,0 +1,48 @@ +package com.infendro.cli.util + +internal const val threshold = 2 + +/** + * Damerau–Levenshtein string distance + */ +internal fun distance(a: String, b: String): Int { + if (a == b) return 0 + + val n = a.length + val m = b.length + + if (n == 0) return m + if (m == 0) return n + + var prev2 = IntArray(m + 1) + var prev1 = IntArray(m + 1) { it } + var curr = IntArray(m + 1) + + for (i in 1..n) { + curr[0] = i + for (j in 1..m) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + + curr[j] = minOf( + prev1[j] + 1, // deletion + curr[j - 1] + 1, // insertion + prev1[j - 1] + cost, // substitution + ) + + // transposition + if (i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) { + curr[j] = minOf( + curr[j], + prev2[j - 2] + 1, + ) + } + } + + val tmp = prev2 + prev2 = prev1 + prev1 = curr + curr = tmp + } + + return prev1[m] +}