implement type-safe fallback commands

This commit is contained in:
2026-01-07 21:44:25 +01:00
parent ecd255479a
commit 921229aa5b
40 changed files with 345 additions and 261 deletions

View File

@@ -1,65 +1,60 @@
package com.infendro.cli.command.context
import com.infendro.cli.Dsl
import com.infendro.cli.command.Argument
import com.infendro.cli.command.Command
import com.infendro.cli.command.Option
import com.infendro.cli.command.argument.Argument
import com.infendro.cli.command.option.Option
import kotlin.reflect.KProperty
@Dsl
class Context internal constructor(
val command: Command,
val commands: List<Cmd>,
val arguments: List<Arg>,
val options: List<Opt>,
private val commands: Map<Command.Fallback.Key<*>, Any>,
private val arguments: Map<Argument<*>, List<Any>>,
private val options: Map<Option<*>, List<Any>>,
) {
class Cmd(
val name: String,
)
class Arg(
val value: Any,
)
class Opt(
val name: String,
val value: Any,
)
internal fun execute() = (command.execute)()
fun help() {
command.help?.let { println(it) }
command.help?.let { print(it) }
}
operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name
@Suppress("UNCHECKED_CAST")
private val <T : Any> Command.Fallback.Key<T>.value: T
get() = commands[this] as T
private val <T : Any> Argument<T>.index: Int
get() = command.arguments.takeWhile { it != this }.sumOf { it.count }
operator fun <T : Any> Command.Fallback.Key<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
value
@Suppress("UNCHECKED_CAST")
private val <T : Any> Argument<T>.value: T?
get() = arguments.getOrNull(index)?.value as? T
private val <T : Any> Argument<T>.values: List<T>
get() = arguments[this] as List<T>
operator fun <T : Any> Argument.Required<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
values.first()
operator fun <T : Any> Argument.OrElse<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
values.firstOrNull() ?: other
operator fun <T : Any> Argument.OrNull<T>.getValue(thisRef: Any?, property: KProperty<*>): T? =
values.firstOrNull()
operator fun <T : Any> Argument.Variable<T>.getValue(thisRef: Any?, property: KProperty<*>): List<T> =
values
@Suppress("UNCHECKED_CAST")
private val <T : Any> Argument.Variable<T>.values: List<T>
get() = arguments.drop(index).take(count).map { it.value as T }
private val <T : Any> Option<T>.values: List<T>
get() = options[this] as List<T>
operator fun <T : Any> Argument.Required<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value!!
operator fun <T : Any> Argument.OrElse<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other
operator fun <T : Any> Argument.OrNull<T>.getValue(thisRef: Any?, property: KProperty<*>): T? = value
operator fun <T : Any> Argument.Variable<T>.getValue(thisRef: Any?, property: KProperty<*>): List<T> = values
operator fun <T : Any> Option.Required<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
values.first()
@Suppress("UNCHECKED_CAST")
private val <T : Any> Option<T>.value: T?
get() = options.firstOrNull { it.name in names }?.value as? T
operator fun <T : Any> Option.OrElse<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
values.firstOrNull() ?: other
@Suppress("UNCHECKED_CAST")
private val <T : Any> Option.Variable<T>.values: List<T>
get() = options.filter { it.name in names }.map { it.value as T }
operator fun <T : Any> Option.OrNull<T>.getValue(thisRef: Any?, property: KProperty<*>): T? =
values.firstOrNull()
operator fun <T : Any> Option.Required<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value!!
operator fun <T : Any> Option.OrElse<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other
operator fun <T : Any> Option.OrNull<T>.getValue(thisRef: Any?, property: KProperty<*>): T? = value
operator fun <T : Any> Option.Variable<T>.getValue(thisRef: Any?, property: KProperty<*>): List<T> = values
operator fun <T : Any> Option.Variable<T>.getValue(thisRef: Any?, property: KProperty<*>): List<T> =
values
}

View File

@@ -1,24 +1,13 @@
package com.infendro.cli.command.context
import com.infendro.cli.command.Command
import com.infendro.cli.command.context.Context.*
import com.infendro.cli.exception.run.*
import com.infendro.cli.parser.Parser
internal class Parser(
internal class ContextParser(
private val root: Command,
private val args: Array<String>,
) {
private var index = 0
private val current: String
get() = args[index]
private val next: String?
get() = args.getOrNull(index)
private fun hasNext() = index < args.size
private fun consume() {
index++
}
sealed class Result {
data class Success(
val context: Context,
@@ -30,40 +19,60 @@ internal class Parser(
data class Failure(
val command: Command,
val exception: RunException,
val error: RuntimeError,
) : Result()
}
fun success(context: Context) = Result.Success(context)
fun help(command: Command) = Result.Help(command)
fun failure(command: Command, exception: RunException) = Result.Failure(command, exception)
private fun success(context: Context) = Result.Success(context)
private fun help(command: Command) = Result.Help(command)
private fun failure(command: Command, exception: RuntimeError) = Result.Failure(command, exception)
private var index = 0
private val current: String
get() = args[index]
private val next: String?
get() = args.getOrNull(index)
private fun hasNext() = index < args.size
private fun consume() {
index++
}
fun parse(): Result {
var command = root
val cmd = mutableListOf<Cmd>()
val arg = mutableListOf<Arg>()
val opt = mutableListOf<Opt>()
// command
var command = root
val commands = mutableMapOf<Command.Fallback.Key<*>, Any>()
while (hasNext()) {
if (current.startsWith("-"))
break
val c = command.commands.firstOrNull { it.name == current || it.fallback }
if (c == null) {
val cmd = command.commands.firstOrNull {
(it is Command.Named && it.name == current) || it is Command.Fallback<*>
}
if (cmd == null) {
when {
command.arguments.isEmpty() -> return failure(command, UnknownCommand(current))
else -> break
}
}
command = c
cmd += Cmd(current)
command = cmd
if (cmd is Command.Fallback<*>) {
val value = when (val result = cmd.parser.parse(current)) {
is Parser.Result.Success<*> -> result.value
is Parser.Result.Failure<*> -> return failure(command, result.error)
}
commands[cmd.key] = value
}
consume()
}
// arguments and options
val arguments = command.arguments.associateWith { mutableListOf<Any>() }
val options = command.options.associateWith { mutableListOf<Any>() }
var endOfOptions = false
var argumentIndex = 0
var argumentCount = 0
@@ -78,8 +87,11 @@ internal class Parser(
val argument = command.arguments.getOrNull(argumentIndex)
?: return failure(command, UnexpectedArgument())
val value = argument.parser.parse(trimmed)
arg += Arg(value)
val value = when (val result = argument.parser.parse(trimmed)) {
is Parser.Result.Success<*> -> result.value
is Parser.Result.Failure<*> -> return failure(command, result.error)
}
arguments[argument]!! += value
argumentCount++
if (argumentCount == argument.count) {
@@ -124,11 +136,15 @@ internal class Parser(
?: return failure(command, UnknownOption(name))
val value = when {
text != null -> option.parser.parse(text)
text != null ->
when (val result = option.parser.parse(current)) {
is Parser.Result.Success<*> -> result.value
is Parser.Result.Failure<*> -> return failure(command, result.error)
}
option.flag -> option.fallback!!
else -> return failure(command, MissingOptionValue(option))
}
opt += Opt(name, value)
options[option]!! += value
}
}
@@ -148,23 +164,18 @@ internal class Parser(
}
// validate arguments
val required = command.arguments.filter { it.required }
var consumed = 0
for (argument in required) {
val needed = if (argument === required.last()) argument.min else argument.count
val delta = arg.size - consumed
if (delta < needed) return failure(command, InvalidArgumentCount(argument, delta))
consumed += needed
for (argument in command.arguments) {
val count = arguments[argument]!!.count()
if (count !in argument.min..argument.count) return failure(command, InvalidArgumentCount(argument, count))
}
// validate options
for (option in command.options) {
val count = opt.count { it.name in option.names }
val count = options[option]!!.count()
if (count !in option.min..option.count) return failure(command, InvalidOptionCount(option, count))
}
val context = Context(command, cmd, arg, opt)
val context = Context(command, commands, arguments, options)
return success(context)
}
}