implement suggestions for unknown command/option

This commit is contained in:
2026-01-09 23:28:02 +01:00
parent ff87ae90f5
commit 250e9d4e0a
10 changed files with 97 additions and 15 deletions

View File

@@ -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 ->

View File

@@ -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."""
}
}

View File

@@ -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."""
}
}

View File

@@ -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"}."""
}

View File

@@ -4,5 +4,5 @@ class MalformedOption(
val option: String,
) : RuntimeFailure() {
override val message: String
get() = """malformed option "$option""""
get() = """Malformed option "$option"."""
}

View File

@@ -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}"."""
}

View File

@@ -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"."""
}

View File

@@ -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"?""")
}
}

View File

@@ -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"?""")
}
}

View File

@@ -0,0 +1,48 @@
package com.infendro.cli.util
internal const val threshold = 2
/**
* DamerauLevenshtein 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]
}