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

@@ -0,0 +1,181 @@
package com.infendro.cli.command.context
import com.infendro.cli.command.Command
import com.infendro.cli.exception.run.*
import com.infendro.cli.parser.Parser
internal class ContextParser(
private val root: Command,
private val args: Array<String>,
) {
sealed class Result {
data class Success(
val context: Context,
) : Result()
data class Help(
val command: Command,
) : Result()
data class Failure(
val command: Command,
val error: RuntimeError,
) : Result()
}
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 {
// command
var command = root
val commands = mutableMapOf<Command.Fallback.Key<*>, Any>()
while (hasNext()) {
if (current.startsWith("-"))
break
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 = 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
while (hasNext()) {
val dashes = current.takeWhile { it == '-' }.count()
val trimmed = current.drop(dashes)
when {
// argument
endOfOptions || dashes == 0 -> {
val argument = command.arguments.getOrNull(argumentIndex)
?: return failure(command, UnexpectedArgument())
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) {
argumentCount = 0
argumentIndex++
}
}
// option
dashes in 1..2 -> {
if (dashes == 2 && trimmed.isEmpty()) {
endOfOptions = true
consume()
continue
}
if (dashes == 2 && trimmed == "help")
return help(command)
val (name, text) = when {
trimmed.contains('=') -> {
val (name, value) = trimmed.split('=', limit = 2)
name to value
}
else -> {
if (next?.startsWith('-') == false) {
consume()
trimmed to next
} else {
trimmed to null
}
}
}
val names = when {
dashes == 1 -> name.map { "$it" }
else -> listOf(name)
}
for (name in names) {
val option = command.options.firstOrNull { name in it.names }
?: return failure(command, UnknownOption(name))
val value = when {
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))
}
options[option]!! += value
}
}
// malformed option
else -> {
when {
current.contains('=') -> {
val (option) = current.split('=', limit = 2)
return failure(command, MalformedOption(option))
}
else -> return failure(command, MalformedOption(current))
}
}
}
consume()
}
// validate arguments
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 = options[option]!!.count()
if (count !in option.min..option.count) return failure(command, InvalidOptionCount(option, count))
}
val context = Context(command, commands, arguments, options)
return success(context)
}
}