From ff87ae90f5f1f4a5c5827d98144a3a29c3bcc1f6 Mon Sep 17 00:00:00 2001 From: Infendro Date: Fri, 9 Jan 2026 20:13:19 +0100 Subject: [PATCH] implement descriptions, improve default help --- README.md | 12 +-- .../com/infendro/cli/command/Command.kt | 27 +++--- .../infendro/cli/command/CommandFactory.kt | 47 ++++++---- .../infendro/cli/command/argument/Argument.kt | 24 +++-- .../cli/command/argument/ArgumentFactory.kt | 30 ++++-- .../cli/command/help/DefaultHelpRenderer.kt | 91 +++++++++++++++---- ...oopHelpRenderer.kt => NoOpHelpRenderer.kt} | 2 +- .../com/infendro/cli/command/option/Option.kt | 45 ++++----- .../cli/command/option/OptionFactory.kt | 34 ++++--- 9 files changed, 204 insertions(+), 108 deletions(-) rename src/commonMain/kotlin/com/infendro/cli/command/help/{NoopHelpRenderer.kt => NoOpHelpRenderer.kt} (76%) diff --git a/README.md b/README.md index f0b7460..67ec8e5 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,20 @@ dependencies { ## Usage ```kotlin -fun main(args: Array) = command("application") { +fun main(args: Array) = command("application", description = "example application") { execute { // define execution of command (e.g., display help) help() } // register command "greet" - +command("greet") { + +command("greet", description = "greet someone") { // declare and register arguments - val speakersArg = +argument.string("speakers").variable(min = 1) + val speakersArg = +argument.string("speakers", description = "the greeters").variable(min = 1) // declare and register options - val greetingOpt = +option.string("greeting", "greet", "g").orElse("Hello") - val nameOpt = +option.string("name", "n").orNull() + val greetingOpt = +option.string("greeting", "greet", "g", description = "the greeting").orElse("Hello") + val nameOpt = +option.string("name", "n", description = "the greetee").orNull() execute { // retrieve the arguments and options @@ -56,7 +56,7 @@ fun main(args: Array) = command("application") { } // register fallback command "value" - +command.fallback.string("value") { valueArg -> + +command.fallback.string("value", description = "execute an arbitrary command") { valueArg -> execute { // retrieve the value used for the fallback command val value by valueArg diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 8d09800..da59ca0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -13,6 +13,7 @@ import com.infendro.cli.util.Regex sealed class Command private constructor( val name: String, + val description: String?, val commands: List, val arguments: List>, val options: List>, @@ -28,32 +29,33 @@ sealed class Command private constructor( is ContextParser.Result.Success -> result.context.execute() is ContextParser.Result.Help -> { val command = result.path.last() - command.renderer.render(result.path)?.let { print(it) } + val help = command.renderer.render(result.path) + if (help != null) print(help) } is ContextParser.Result.Failure -> { println("error: ${result.failure.message}") val command = result.path.last() - command.renderer.render(result.path)?.let { - println() - print(it) - } + val help = command.renderer.render(result.path) + if (help != null) print("\n$help") } } } class Named( name: String, + description: String?, commands: List, arguments: List>, options: List>, execute: Context.() -> Unit, renderer: HelpRenderer, - ) : Command(name, commands, arguments, options, execute, renderer) { + ) : Command(name, description, commands, arguments, options, execute, renderer) { class Builder( name: String, - ) : Command.Builder(name) { + description: String?, + ) : Command.Builder(name, description) { override fun create() = - Named(name, commands, arguments, options, execute, renderer) + Named(name, description, commands, arguments, options, execute, renderer) } } @@ -61,20 +63,22 @@ sealed class Command private constructor( val key: Key, val parser: Parser, name: String, + description: String?, commands: List, arguments: List>, options: List>, execute: Context.() -> Unit, renderer: HelpRenderer, - ) : Command(name, commands, arguments, options, execute, renderer) { + ) : Command(name, description, commands, arguments, options, execute, renderer) { class Builder( val parser: Parser, name: String, - ) : Command.Builder>(name) { + description: String?, + ) : Command.Builder>(name, description) { internal val key = Key() override fun create() = - Fallback(key, parser, name, commands, arguments, options, execute, renderer) + Fallback(key, parser, name, description, commands, arguments, options, execute, renderer) } class Key @@ -83,6 +87,7 @@ sealed class Command private constructor( @Dsl sealed class Builder( protected val name: String, + protected val description: String?, ) { protected val commands = mutableListOf() protected val arguments = mutableListOf>() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt index bef9ef8..1236270 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt @@ -5,35 +5,46 @@ import com.infendro.cli.parser.* class CommandFactory internal constructor() { inner class FallbackFactory internal constructor() { - fun string(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(StringParser, name, block) + fun string(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(StringParser, name, description, block) - fun int(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(IntParser, name, block) + fun int(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(IntParser, name, description, block) - fun long(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(LongParser, name, block) + fun long(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(LongParser, name, description, block) - fun float(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(FloatParser, name, block) + fun float(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(FloatParser, name, description, block) - fun double(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(DoubleParser, name, block) + fun double(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(DoubleParser, name, description, block) - fun boolean(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(BooleanParser, name, block) + fun boolean(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(BooleanParser, name, description, block) - inline fun > enum(name: String, noinline block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(enumParser(), name, block) + inline fun > enum( + name: String, + description: String, + noinline block: Builder<*>.(Fallback.Key) -> Unit, + ) = + fallback(enumParser(), name, description, block) } - fun fallback(parser: Parser, name: String, block: Fallback.Builder.(Fallback.Key) -> Unit) = - Fallback.Builder(parser, name).apply { block(key) }.build() + fun fallback( + parser: Parser, + name: String, + description: String? = null, + block: Fallback.Builder.(Fallback.Key) -> Unit, + ) = Fallback.Builder(parser, name, description).apply { block(key) }.build() val fallback = FallbackFactory() } -fun command(name: String, block: Builder<*>.() -> Unit) = - Named.Builder(name).apply(block).build() +fun command( + name: String, + description: String? = null, + block: Builder<*>.() -> Unit, +) = Named.Builder(name, description).apply(block).build() val command = CommandFactory() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt index 696b794..542e721 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt @@ -8,6 +8,7 @@ import com.infendro.cli.util.Regex.ARGUMENT sealed class Argument( val parser: Parser, val name: String, + val description: String?, val min: Int, val max: Int?, ) { @@ -34,27 +35,36 @@ sealed class Argument( class Required( parser: Parser, name: String, - ) : Argument(parser, name, min = 1, max = 1) { - fun orElse(other: T) = OrElse(parser, name, other) - fun orNull() = OrNull(parser, name) - fun variable(min: Int = 0, max: Int? = null) = Variable(parser, name, min, max) + description: String?, + ) : Argument(parser, name, description, min = 1, max = 1) { + fun orElse(other: T) = + OrElse(parser, name, description, other) + + fun orNull() = + OrNull(parser, name, description) + + fun variable(min: Int = 0, max: Int? = null) = + Variable(parser, name, description, min, max) } class OrElse( parser: Parser, name: String, + description: String?, val other: T, - ) : Argument(parser, name, min = 0, max = 1) + ) : Argument(parser, name, description, min = 0, max = 1) class OrNull( parser: Parser, name: String, - ) : Argument(parser, name, min = 0, max = 1) + description: String?, + ) : Argument(parser, name, description, min = 0, max = 1) class Variable( parser: Parser, name: String, + description: String?, min: Int, max: Int?, - ) : Argument(parser, name, min, max) + ) : Argument(parser, name, description, min, max) } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt index fc73224..38fcf6e 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt @@ -3,17 +3,29 @@ package com.infendro.cli.command.argument import com.infendro.cli.parser.* object ArgumentFactory { - fun string(name: String) = argument(StringParser, name) - fun int(name: String) = argument(IntParser, name) - fun long(name: String) = argument(LongParser, name) - fun float(name: String) = argument(FloatParser, name) - fun double(name: String) = argument(DoubleParser, name) - fun boolean(name: String) = argument(BooleanParser, name) + fun string(name: String, description: String? = null) = + argument(StringParser, name, description) - inline fun > enum(name: String) = argument(enumParser(), name) + fun int(name: String, description: String? = null) = + argument(IntParser, name, description) + + fun long(name: String, description: String? = null) = + argument(LongParser, name, description) + + fun float(name: String, description: String? = null) = + argument(FloatParser, name, description) + + fun double(name: String, description: String? = null) = + argument(DoubleParser, name, description) + + fun boolean(name: String, description: String? = null) = + argument(BooleanParser, name, description) + + inline fun > enum(name: String, description: String? = null) = + argument(enumParser(), name, description) } val argument = ArgumentFactory -fun argument(parser: Parser, name: String) = - Argument.Required(parser, name) +fun argument(parser: Parser, name: String, description: String? = null) = + Argument.Required(parser, name, description) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt index d1027cb..79febeb 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -10,6 +10,10 @@ object DefaultHelpRenderer : HelpRenderer { val command = path.last() usage(path) + if (command.description != null) { + appendLine() + appendLine(command.description) + } if (command.commands.isNotEmpty()) { appendLine() commands(command.commands) @@ -70,37 +74,66 @@ object DefaultHelpRenderer : HelpRenderer { } private fun StringBuilder.commands(commands: List) { - appendLine("Commands:") - for (command in commands) { + val rows = commands.map { command -> val name = when { - command is Command.Fallback<*> -> { - val name = command.name - val type = command.parser.type.name - "<$name> ($type)" - } + command is Command.Fallback<*> -> "<${command.name}>" else -> command.name } - appendLine(" * $name") + val type = when { + command is Command.Fallback<*> -> command.parser.type.name + else -> null + } + val description = command.description + CommandRow(name, type, description) + } + val nameWidth = rows.maxOf { it.name.length } + + appendLine("Commands:") + when { + rows.any { it.type != null } -> { + val typeWidth = rows.maxOf { it.type?.length ?: 0 } + + rows.forEach { row -> + appendLine(" ${row.name.padEnd(nameWidth)} ${(row.type ?: "").padEnd(typeWidth)} ${row.description ?: ""}") + } + } + else -> rows.forEach { row -> + appendLine(" ${row.name.padEnd(nameWidth)} ${row.description ?: ""}") + } } } private fun StringBuilder.arguments(arguments: List>) { - appendLine("Arguments:") - for (argument in arguments) { + val rows = arguments.map { argument -> val name = argument.name val type = argument.parser.type.name - val range = renderRangeFull(argument.min, argument.max) - appendLine(" * $name ($type, $range)") + val range = renderRange(argument.min, argument.max) + val description = argument.description + ArgumentRow(name, type, range, description) + } + val nameWidth = rows.maxOf { it.name.length } + val constraintsWidth = rows.maxOf { it.constraints.length } + + appendLine("Arguments:") + for (row in rows) { + appendLine(" ${row.name.padEnd(nameWidth)} ${row.constraints.padEnd(constraintsWidth)} ${row.description ?: ""}") } } private fun StringBuilder.options(options: List>) { - appendLine("Options:") - for (option in options) { + val rows = options.map { option -> val name = option.names.joinToString(" | ") { it.withPrefix() } val type = option.parser.type.name - val range = renderRangeFull(option.min, option.max) - appendLine(" * $name ($type, $range)") + val range = renderRange(option.min, option.max) + val description = option.description + OptionRow(name, type, range, description) + } + val nameWidth = rows.maxOf { it.name.length } + val constraintsWidth = rows.maxOf { it.constraints.length } + + appendLine("Options:") + for (row in rows) { + appendLine(" ${row.name.padEnd(nameWidth)} ${row.constraints.padEnd(constraintsWidth)} ${row.description ?: ""}") } } @@ -127,4 +160,30 @@ object DefaultHelpRenderer : HelpRenderer { else -> "between $min and $max" } } + + data class CommandRow( + val name: String, + val type: String?, + val description: String?, + ) + + data class ArgumentRow( + val name: String, + val type: String, + val range: String, + val description: String?, + ) { + val constraints: String + get() = "$type$range" + } + + data class OptionRow( + val name: String, + val type: String, + val range: String, + val description: String?, + ) { + val constraints: String + get() = "$type$range" + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/NoOpHelpRenderer.kt similarity index 76% rename from src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt rename to src/commonMain/kotlin/com/infendro/cli/command/help/NoOpHelpRenderer.kt index 5e6c09b..677865b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/NoOpHelpRenderer.kt @@ -2,6 +2,6 @@ package com.infendro.cli.command.help import com.infendro.cli.command.Command -object NoopHelpRenderer : HelpRenderer { +object NoOpHelpRenderer : HelpRenderer { override fun render(path: List) = null } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt index 7073274..8b2aca0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt @@ -10,6 +10,7 @@ sealed class Option( val parser: Parser, val fallback: T?, val names: List, + val description: String?, val min: Int, val max: Int?, ) { @@ -44,53 +45,39 @@ sealed class Option( parser: Parser, fallback: T?, names: List, - ) : Option(parser, fallback, names, min = 1, max = 1) { - constructor( - parser: Parser, - names: List, - ) : this(parser, null, names) + description: String?, + ) : Option(parser, fallback, names, description, min = 1, max = 1) { + fun orElse(other: T) = + OrElse(parser, fallback, names, description, other) - fun orElse(other: T) = OrElse(parser, fallback, names, other) - fun orNull() = OrNull(parser, fallback, names) - fun variable(min: Int = 0, max: Int? = null) = Variable(parser, fallback, names, min, max) + fun orNull() = + OrNull(parser, fallback, names, description) + + fun variable(min: Int = 0, max: Int? = null) = + Variable(parser, fallback, names, description, min, max) } class OrElse( parser: Parser, fallback: T?, names: List, + description: String?, val other: T, - ) : Option(parser, fallback, names, min = 0, max = 1) { - constructor( - parser: Parser, - names: List, - other: T, - ) : this(parser, null, names, other) - } + ) : Option(parser, fallback, names, description, min = 0, max = 1) class OrNull( parser: Parser, fallback: T?, names: List, - ) : Option(parser, fallback, names, min = 0, max = 1) { - constructor( - parser: Parser, - names: List, - ) : this(parser, null, names) - } + description: String?, + ) : Option(parser, fallback, names, description, min = 0, max = 1) class Variable( parser: Parser, fallback: T?, names: List, + description: String?, min: Int, max: Int?, - ) : Option(parser, fallback, names, min, max) { - constructor( - parser: Parser, - names: List, - min: Int, - max: Int?, - ) : this(parser, null, names, min, max) - } + ) : Option(parser, fallback, names, description, min, max) } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt index 95924bf..fea7dd1 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt @@ -3,20 +3,32 @@ package com.infendro.cli.command.option import com.infendro.cli.parser.* object OptionFactory { - fun string(vararg names: String) = option(StringParser, *names) - fun int(vararg names: String) = option(IntParser, *names) - fun long(vararg names: String) = option(LongParser, *names) - fun float(vararg names: String) = option(FloatParser, *names) - fun double(vararg names: String) = option(DoubleParser, *names) - fun boolean(vararg names: String) = option(BooleanParser, fallback = true, *names) + fun string(vararg names: String, description: String? = null) = + option(StringParser, *names, description = description) - inline fun > enum(vararg names: String) = option(enumParser(), *names) + fun int(vararg names: String, description: String? = null) = + option(IntParser, *names, description = description) + + fun long(vararg names: String, description: String? = null) = + option(LongParser, *names, description = description) + + fun float(vararg names: String, description: String? = null) = + option(FloatParser, *names, description = description) + + fun double(vararg names: String, description: String? = null) = + option(DoubleParser, *names, description = description) + + fun boolean(vararg names: String, description: String? = null) = + option(BooleanParser, fallback = true, *names, description = description) + + inline fun > enum(vararg names: String, description: String? = null) = + option(enumParser(), *names, description = description) } val option = OptionFactory -fun option(parser: Parser, vararg names: String) = - Option.Required(parser, names.asList()) +fun option(parser: Parser, vararg names: String, description: String? = null) = + Option.Required(parser, fallback = null, names.asList(), description) -fun option(parser: Parser, fallback: T, vararg names: String) = - Option.Required(parser, fallback, names.asList()) +fun option(parser: Parser, fallback: T, vararg names: String, description: String? = null) = + Option.Required(parser, fallback, names.asList(), description)