From ecd255479abd9e2da89442c2170f1ea9b92fb9fd Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 7 Jan 2026 18:53:35 +0100 Subject: [PATCH] implement automatic help --- README.md | 2 +- .../com/infendro/cli/command/Argument.kt | 4 +- .../com/infendro/cli/command/Command.kt | 67 +++++++---- .../kotlin/com/infendro/cli/command/Option.kt | 2 +- .../infendro/cli/command/context/Context.kt | 6 +- .../infendro/cli/command/context/Parser.kt | 113 +++++++++++------- .../cli/command/help/DefaultHelpRenderer.kt | 101 ++++++++++++++++ .../infendro/cli/command/help/HelpRenderer.kt | 7 ++ .../cli/command/help/NoopHelpRenderer.kt | 7 ++ .../cli/exception/run/InvalidValue.kt | 2 +- .../com/infendro/cli/parser/BooleanParser.kt | 7 +- .../com/infendro/cli/parser/DoubleParser.kt | 7 +- .../com/infendro/cli/parser/EnumParser.kt | 5 +- .../com/infendro/cli/parser/FloatParser.kt | 7 +- .../com/infendro/cli/parser/IntParser.kt | 7 +- .../com/infendro/cli/parser/LongParser.kt | 7 +- .../kotlin/com/infendro/cli/parser/Parser.kt | 9 +- .../com/infendro/cli/parser/StringParser.kt | 5 + 18 files changed, 277 insertions(+), 88 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt diff --git a/README.md b/README.md index ec38adf..5b5478f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ dependencies { fun main(args: Array) = cli("application") { execute { // define execution of command (e.g., display help) - println("Usage: application [arguments] [options]") + help() } // declare command "greet" diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index c9a39fd..ea7d1fa 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -10,9 +10,7 @@ sealed class Argument( val max: Int?, ) { init { - when { - min !in 0..count -> throw InvalidRange() - } + if (min !in 0..count || max == 0) throw InvalidRange() } val required: Boolean diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index a26886d..cb9d0f8 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -3,25 +3,42 @@ package com.infendro.cli.command import com.infendro.cli.Dsl import com.infendro.cli.command.context.Context import com.infendro.cli.command.context.Parser +import com.infendro.cli.command.help.DefaultHelpRenderer +import com.infendro.cli.command.help.HelpRenderer import com.infendro.cli.exception.build.* -import com.infendro.cli.exception.run.RunException import com.infendro.cli.util.Regex.ARGUMENT import com.infendro.cli.util.Regex.COMMAND import com.infendro.cli.util.Regex.OPTION import kotlin.reflect.KProperty class Command private constructor( + val parent: Command?, val name: String, val fallback: Boolean, val commands: List, val arguments: List>, val options: List>, internal val execute: Context.() -> Unit, + internal val renderer: HelpRenderer, ) { - fun run(args: Array) = try { - Parser.parse(this, args).execute() - } catch (e: RunException) { - println(e.message) + val path: List + get() = when { + parent == null -> listOf(this) + else -> parent.path + this + } + + val help: String? + get() = renderer.render(this) + + fun run(args: Array) { + when (val result = Parser(this, args).parse()) { + is Parser.Result.Success -> result.context.execute() + is Parser.Result.Help -> result.command.help?.let { println(it) } + is Parser.Result.Failure -> { + println("error: ${result.exception.message}") + result.command.help?.let { println("\n$it") } + } + } } @Dsl @@ -29,8 +46,10 @@ class Command private constructor( private val level: Int, private val name: String, private val fallback: Boolean, + var renderer: HelpRenderer, ) { - private val commands = mutableListOf() + private var parent: Command? = null + private val builders = mutableListOf() private val arguments = mutableListOf>() private val options = mutableListOf>() private lateinit var _execute: Context.() -> Unit @@ -40,12 +59,12 @@ class Command private constructor( fun command(name: String, block: Builder.() -> Unit) { validateCommand(name) - commands += Builder(level + 1, name, fallback = false).apply(block).build() + builders += Builder(level + 1, name, fallback = false, renderer).apply(block) } fun fallback(name: String, block: Builder.(Key) -> Unit) { validateCommand(name) - commands += Builder(level + 1, name, fallback = true).apply { block(key) }.build() + builders += Builder(level + 1, name, fallback = true, renderer).apply { block(key) } } fun argument(argument: Argument<*>) { @@ -78,19 +97,24 @@ class Command private constructor( _execute = {} validate() - return Command(name, fallback, commands, arguments, options, _execute) + + val commands = mutableListOf() + val command = Command(parent, name, fallback, commands, arguments, options, _execute, renderer) + for (builder in builders) { + builder.parent = command + commands += builder.build() + } + return command } private fun validateCommand(name: String) { - if (commands.isNotEmpty()) { - val last = commands.last() - when { - last.fallback -> throw InvalidCommandOrder() - } + if (builders.isNotEmpty()) { + val last = builders.last() + if (last.fallback) throw InvalidCommandOrder() } when { !name.matches(COMMAND) -> throw InvalidCommand(name) - commands.any { it.name == name } -> throw DuplicateCommand(name) + builders.any { it.name == name } -> throw DuplicateCommand(name) } } @@ -102,9 +126,7 @@ class Command private constructor( argument.required && last.optional -> throw InvalidArgumentOrder() } } - when { - !argument.name.matches(ARGUMENT) -> throw InvalidArgument(argument.name) - } + if (!argument.name.matches(ARGUMENT)) throw InvalidArgument(argument.name) } private fun validateOption(option: Option<*>) { @@ -117,14 +139,11 @@ class Command private constructor( } private fun validateExecute() { - if (::_execute.isInitialized) - throw DuplicateExecute() + if (::_execute.isInitialized) throw DuplicateExecute() } private fun validate() { - if (commands.any { it.fallback } && arguments.isNotEmpty()) { - throw InvalidFallback() - } + if (builders.any { it.fallback } && arguments.isNotEmpty()) throw InvalidFallback() } } @@ -134,4 +153,4 @@ class Command private constructor( } fun cli(name: String, block: Command.Builder.() -> Unit) = - Command.Builder(-1, name, fallback = false).apply(block).build() + Command.Builder(-1, name, fallback = false, DefaultHelpRenderer).apply(block).build() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index 5da02f1..5bc9d1c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -33,7 +33,7 @@ sealed class Option( init { when { names.isEmpty() -> throw MissingOptionName() - min !in 0..count -> throw InvalidRange() + min !in 0..count || max == 0 -> throw InvalidRange() } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt index 42aa429..bfc024a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -10,8 +10,8 @@ import kotlin.reflect.KProperty class Context internal constructor( val command: Command, val commands: List, - val options: List, val arguments: List, + val options: List, ) { class Cmd( val name: String, @@ -28,6 +28,10 @@ class Context internal constructor( internal fun execute() = (command.execute)() + fun help() { + command.help?.let { println(it) } + } + operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name private val Argument.index: Int diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt index 42e2cdf..19cc5e9 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt @@ -4,26 +4,55 @@ import com.infendro.cli.command.Command import com.infendro.cli.command.context.Context.* import com.infendro.cli.exception.run.* -internal object Parser { - fun parse(root: Command, args: Array): Context { +internal class Parser( + private val root: Command, + private val args: Array, +) { + 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, + ) : Result() + + data class Help( + val command: Command, + ) : Result() + + data class Failure( + val command: Command, + val exception: RunException, + ) : 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) + + fun parse(): Result { var command = root val cmd = mutableListOf() val arg = mutableListOf() val opt = mutableListOf() - var i = 0 - - // commands - while (i < args.size) { - val current = args[i] - + // command + while (hasNext()) { if (current.startsWith("-")) break val c = command.commands.firstOrNull { it.name == current || it.fallback } if (c == null) { when { - command.arguments.isEmpty() -> throw UnknownCommand(current) + command.arguments.isEmpty() -> return failure(command, UnknownCommand(current)) else -> break } } @@ -31,24 +60,25 @@ internal object Parser { command = c cmd += Cmd(current) - i++ + consume() } // arguments and options var endOfOptions = false var argumentIndex = 0 var argumentCount = 0 - while (i < args.size) { - val dashes = args[i].takeWhile { it == '-' }.count() - val current = args[i].drop(dashes) + + while (hasNext()) { + val dashes = current.takeWhile { it == '-' }.count() + val trimmed = current.drop(dashes) when { // argument endOfOptions || dashes == 0 -> { val argument = command.arguments.getOrNull(argumentIndex) - ?: throw UnexpectedArgument() + ?: return failure(command, UnexpectedArgument()) - val value = argument.parser.parse(current) + val value = argument.parser.parse(trimmed) arg += Arg(value) argumentCount++ @@ -60,25 +90,26 @@ internal object Parser { // option dashes in 1..2 -> { - if (dashes == 2 && current.isEmpty()) { + if (dashes == 2 && trimmed.isEmpty()) { endOfOptions = true - i++ + consume() continue } + if (dashes == 2 && trimmed == "help") + return help(command) + val (name, text) = when { - current.contains('=') -> { - val (name, value) = current.split('=', limit = 2) + trimmed.contains('=') -> { + val (name, value) = trimmed.split('=', limit = 2) name to value } - else -> { - val next = args.getOrNull(i + 1) - if (next != null && !next.startsWith('-')) { - i++ - current to next + if (next?.startsWith('-') == false) { + consume() + trimmed to next } else { - current to null + trimmed to null } } } @@ -90,12 +121,12 @@ internal object Parser { for (name in names) { val option = command.options.firstOrNull { name in it.names } - ?: throw UnknownOption(name) + ?: return failure(command, UnknownOption(name)) val value = when { text != null -> option.parser.parse(text) option.flag -> option.fallback!! - else -> throw MissingOptionValue(option) + else -> return failure(command, MissingOptionValue(option)) } opt += Opt(name, value) } @@ -104,40 +135,36 @@ internal object Parser { // malformed option else -> { when { - args[i].contains('=') -> { - val (option) = args[i].split('=', limit = 2) - throw MalformedOption(option) + current.contains('=') -> { + val (option) = current.split('=', limit = 2) + return failure(command, MalformedOption(option)) } - else -> throw MalformedOption(args[i]) + else -> return failure(command, MalformedOption(current)) } } } - i++ + consume() } - validateArguments(command, arg) - validateOptions(command, opt) - - return Context(command, cmd, opt, arg) - } - - private fun validateArguments(command: Command, arg: List) { + // 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) throw InvalidArgumentCount(argument, delta) + if (delta < needed) return failure(command, InvalidArgumentCount(argument, delta)) consumed += needed } - } - private fun validateOptions(command: Command, opt: List) { + // validate options for (option in command.options) { val count = opt.count { it.name in option.names } - if (count !in option.min..option.count) throw InvalidOptionCount(option, count) + if (count !in option.min..option.count) return failure(command, InvalidOptionCount(option, count)) } + + val context = Context(command, cmd, arg, opt) + return success(context) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt new file mode 100644 index 0000000..5c7379e --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -0,0 +1,101 @@ +package com.infendro.cli.command.help + +import com.infendro.cli.command.Argument +import com.infendro.cli.command.Command +import com.infendro.cli.command.Option + +object DefaultHelpRenderer : HelpRenderer { + override fun render(command: Command) = buildString { + usage(command) + if (command.commands.isNotEmpty()) { + appendLine() + commands(command.commands) + } + if (command.arguments.isNotEmpty()) { + appendLine() + arguments(command.arguments) + } + if (command.options.isNotEmpty()) { + appendLine() + options(command.options) + } + } + + private fun StringBuilder.usage(command: Command) { + append("Usage:") + for (command in command.path) { + append(" ") + when { + command.fallback -> append("<${command.name}>") + else -> append(command.name) + } + } + for (argument in command.arguments) { + append(" ") + when { + argument.min == 1 && argument.max == 1 -> append("<${argument.name}>") + argument.min == 0 && argument.max == 1 -> append("[<${argument.name}>]") + else -> { + val range = when { + argument.unbounded -> "{${argument.min},}" + argument.min == argument.max -> "{${argument.min}}" + else -> "{${argument.min},${argument.max}}" + } + append("<${argument.name}>$range") + } + } + } + for (option in command.options) { + append(" ") + val text = buildString { + val names = option.names.joinToString("|") { it.withPrefix() } + append(names) + append(" ") + when { + option.flag -> append("[]") + else -> append("") + } + } + when { + option.min == 1 && option.max == 1 -> append("($text)") + option.min == 0 && option.max == 1 -> append("[$text]") + else -> { + val range = when { + option.unbounded -> "{${option.min},}" + option.min == option.max -> "{${option.min}}" + else -> "{${option.min},${option.max}}" + } + append("($text)$range") + } + } + } + appendLine() + } + + private fun StringBuilder.commands(commands: List) { + appendLine("Commands:") + for (command in commands) { + appendLine(" * ${command.name}") + } + } + + private fun StringBuilder.arguments(arguments: List>) { + appendLine("Arguments:") + for (argument in arguments) { + appendLine(" * ${argument.name}") + } + } + + private fun StringBuilder.options(options: List>) { + appendLine("Options:") + for (option in options) { + val name = option.names.joinToString(" | ") { it.withPrefix() } + appendLine(" * $name") + } + } + + private fun String.withPrefix() = when { + length == 1 -> "-$this" + else -> "--$this" + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt new file mode 100644 index 0000000..62a701c --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt @@ -0,0 +1,7 @@ +package com.infendro.cli.command.help + +import com.infendro.cli.command.Command + +interface HelpRenderer { + fun render(command: Command): String? +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt new file mode 100644 index 0000000..f92b64e --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt @@ -0,0 +1,7 @@ +package com.infendro.cli.command.help + +import com.infendro.cli.command.Command + +object NoopHelpRenderer : HelpRenderer { + override fun render(command: Command) = null +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt index bdd69e3..dd7fcf5 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt @@ -7,5 +7,5 @@ class InvalidValue( val type: KClass<*>, ) : RunException() { override val message: String - get() = """"$value" cannot be converted to ${type.simpleName}""" + get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt index 5030413..d699bf1 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -1,11 +1,14 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass object BooleanParser : Parser { + override val type: KClass + get() = Boolean::class + override fun parse(text: String): Boolean = when (text) { "true", "t" -> true "false", "f" -> false - else -> throw InvalidValue(text, Boolean::class) + else -> invalid(text) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt index 20f8774..f0286c7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -1,8 +1,11 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass object DoubleParser : Parser { + override val type: KClass + get() = Double::class + override fun parse(text: String): Double = text.toDoubleOrNull() - ?: throw InvalidValue(text, Double::class) + ?: invalid(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt index 0dd7806..bf9448f 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -1,15 +1,14 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue import kotlin.enums.enumEntries import kotlin.reflect.KClass class EnumParser>( - private val type: KClass, + override val type: KClass, private val values: List, ) : Parser { override fun parse(text: String): T = values.firstOrNull { it.name == text } - ?: throw InvalidValue(text, type) + ?: invalid(text) } inline fun > enumParser() = EnumParser(T::class, enumEntries()) diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt index 65a774a..aa3c3e2 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -1,8 +1,11 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass object FloatParser : Parser { + override val type: KClass + get() = Float::class + override fun parse(text: String): Float = text.toFloatOrNull() - ?: throw InvalidValue(text, Float::class) + ?: invalid(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt index 4df6a2e..570c7bd 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -1,8 +1,11 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass object IntParser : Parser { + override val type: KClass + get() = Int::class + override fun parse(text: String): Int = text.toIntOrNull() - ?: throw InvalidValue(text, Int::class) + ?: invalid(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt index 64152bc..4e8319b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -1,8 +1,11 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass object LongParser : Parser { + override val type: KClass + get() = Long::class + override fun parse(text: String): Long = text.toLongOrNull() - ?: throw InvalidValue(text, Long::class) + ?: invalid(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt index fc15d53..ee9dfa2 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -1,5 +1,12 @@ package com.infendro.cli.parser -interface Parser { +import com.infendro.cli.exception.run.InvalidValue +import kotlin.reflect.KClass + +interface Parser { + val type: KClass + fun parse(text: String): T + + fun invalid(text: String): Nothing = throw InvalidValue(text, type) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt index c3c6ddc..be7f9b3 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -1,5 +1,10 @@ package com.infendro.cli.parser +import kotlin.reflect.KClass + object StringParser : Parser { + override val type: KClass + get() = String::class + override fun parse(text: String) = text }