From 74a93246608dc5b4d72034f4f544b5c36b91745c Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 31 Dec 2025 18:42:22 +0100 Subject: [PATCH 01/20] rename Arguments to Options, implement Arguments --- .../com/infendro/cli/argument/Argument.kt | 44 ------- .../com/infendro/cli/argument/Boolean.kt | 30 ----- .../kotlin/com/infendro/cli/argument/Int.kt | 33 ----- .../com/infendro/cli/argument/String.kt | 27 ---- .../com/infendro/cli/command/Argument.kt | 79 ++++++++++++ .../com/infendro/cli/command/Command.kt | 110 +++++++++------- .../com/infendro/cli/command/Context.kt | 56 ++++++++ .../com/infendro/cli/command/Execution.kt | 94 -------------- .../kotlin/com/infendro/cli/command/Option.kt | 93 ++++++++++++++ .../kotlin/com/infendro/cli/command/Parser.kt | 120 ++++++++++++++++++ .../infendro/cli/exception/CliException.kt | 3 - .../exception/build/ArgumentOrderException.kt | 3 + ...eException.kt => CommandOrderException.kt} | 2 +- .../build/DuplicateArgumentException.kt | 5 - ...ception.kt => DuplicateOptionException.kt} | 2 +- .../build/IllegalOptionNameException.kt | 5 + .../build/InvalidFallbackException.kt | 3 + .../build/NoArgumentNameException.kt | 3 - .../exception/build/NoOptionNameException.kt | 3 + .../cli/exception/run/CliException.kt | 3 + .../run/DuplicateArgumentException.kt | 10 -- .../exception/run/DuplicateOptionException.kt | 8 ++ .../run/MalformedArgumentException.kt | 10 -- .../exception/run/MalformedOptionException.kt | 8 ++ .../exception/run/MissingArgumentException.kt | 8 +- .../exception/run/MissingOptionException.kt | 8 ++ .../cli/exception/run/ParseException.kt | 2 - .../run/TooManyArgumentsException.kt | 6 + .../exception/run/UnknownArgumentException.kt | 10 -- .../exception/run/UnknownCommandException.kt | 2 - .../exception/run/UnknownOptionException.kt | 8 ++ .../com/infendro/cli/parser/BooleanParser.kt | 11 ++ .../com/infendro/cli/parser/IntParser.kt | 11 ++ .../kotlin/com/infendro/cli/parser/Parser.kt | 5 + .../com/infendro/cli/parser/StringParser.kt | 7 + .../kotlin/com/infendro/cli/util/Regex.kt | 6 +- 36 files changed, 506 insertions(+), 332 deletions(-) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/argument/Argument.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/argument/Boolean.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/argument/Int.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/argument/String.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Argument.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Context.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Execution.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Option.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Parser.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt rename src/commonMain/kotlin/com/infendro/cli/exception/build/{NoExecuteException.kt => CommandOrderException.kt} (50%) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateArgumentException.kt rename src/commonMain/kotlin/com/infendro/cli/exception/build/{IllegalArgumentNameException.kt => DuplicateOptionException.kt} (68%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/NoArgumentNameException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateArgumentException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedArgumentException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownArgumentException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/argument/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/argument/Argument.kt deleted file mode 100644 index 540b622..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/argument/Argument.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.infendro.cli.argument - -import com.infendro.cli.exception.build.NoArgumentNameException - -sealed class Argument( - val names: List, - val parser: Parser, -) { - abstract val required: Boolean - - val name: String - get() = names[0] - - init { - if (names.isEmpty()) - throw NoArgumentNameException() - } - - interface Parser { - fun parse(text: String?): T - } - - class Single( - names: List, - parser: Parser, - ) : Argument(names, parser) { - override val required: Boolean = true - } - - class SingleOrElse( - names: List, - parser: Parser, - val other: T, - ) : Argument(names, parser) { - override val required: Boolean = false - } - - class SingleOrNull( - names: List, - parser: Parser, - ) : Argument(names, parser) { - override val required: Boolean = false - } -} diff --git a/src/commonMain/kotlin/com/infendro/cli/argument/Boolean.kt b/src/commonMain/kotlin/com/infendro/cli/argument/Boolean.kt deleted file mode 100644 index 776275e..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/argument/Boolean.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.infendro.cli.argument - -import com.infendro.cli.command.Command -import com.infendro.cli.exception.run.ParseException - -object BooleanParser : Argument.Parser { - override fun parse(text: String?): Boolean = try { - text?.toBooleanStrict() ?: true - } catch (_: IllegalArgumentException) { - throw ParseException(text, "Boolean") - } -} - -fun boolean(vararg names: String) = - Argument.Single(names.asList(), BooleanParser) - -fun booleanOrElse(vararg names: String, other: Boolean) = - Argument.SingleOrElse(names.asList(), BooleanParser, other) - -fun booleanOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), BooleanParser) - -fun Command.Builder.boolean(vararg names: String) = - Argument.Single(names.asList(), BooleanParser).also { argument(it) } - -fun Command.Builder.booleanOrElse(vararg names: String, other: Boolean) = - Argument.SingleOrElse(names.asList(), BooleanParser, other).also { argument(it) } - -fun Command.Builder.booleanOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), BooleanParser).also { argument(it) } diff --git a/src/commonMain/kotlin/com/infendro/cli/argument/Int.kt b/src/commonMain/kotlin/com/infendro/cli/argument/Int.kt deleted file mode 100644 index eb7f060..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/argument/Int.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.infendro.cli.argument - -import com.infendro.cli.command.Command -import com.infendro.cli.exception.run.ParseException - -object IntParser : Argument.Parser { - override fun parse(text: String?): Int = try { - text!!.toInt() - } catch (e: Exception) { - when (e) { - is NullPointerException, is IllegalArgumentException -> throw ParseException(text, "Int") - else -> throw e - } - } -} - -fun int(vararg names: String) = - Argument.Single(names.asList(), IntParser) - -fun intOrElse(vararg names: String, other: Int) = - Argument.SingleOrElse(names.asList(), IntParser, other) - -fun intOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), IntParser) - -fun Command.Builder.int(vararg names: String) = - Argument.Single(names.asList(), IntParser).also { argument(it) } - -fun Command.Builder.intOrElse(vararg names: String, other: Int) = - Argument.SingleOrElse(names.asList(), IntParser, other).also { argument(it) } - -fun Command.Builder.intOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), IntParser).also { argument(it) } diff --git a/src/commonMain/kotlin/com/infendro/cli/argument/String.kt b/src/commonMain/kotlin/com/infendro/cli/argument/String.kt deleted file mode 100644 index 5d835a9..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/argument/String.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.infendro.cli.argument - -import com.infendro.cli.command.Command -import com.infendro.cli.exception.run.ParseException - -object StringParser : Argument.Parser { - override fun parse(text: String?): String = - text ?: throw ParseException(text, "String") -} - -fun string(vararg names: String) = - Argument.Single(names.asList(), StringParser) - -fun stringOrElse(vararg names: String, other: String) = - Argument.SingleOrElse(names.asList(), StringParser, other) - -fun stringOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), StringParser) - -fun Command.Builder.string(vararg names: String) = - Argument.Single(names.asList(), StringParser).also { argument(it) } - -fun Command.Builder.stringOrElse(vararg names: String, other: String) = - Argument.SingleOrElse(names.asList(), StringParser, other).also { argument(it) } - -fun Command.Builder.stringOrNull(vararg names: String) = - Argument.SingleOrNull(names.asList(), StringParser).also { argument(it) } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt new file mode 100644 index 0000000..53a4ec7 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -0,0 +1,79 @@ +package com.infendro.cli.command + +import com.infendro.cli.parser.BooleanParser +import com.infendro.cli.parser.IntParser +import com.infendro.cli.parser.Parser +import com.infendro.cli.parser.StringParser + +sealed class Argument( + val parser: Parser, +) { + abstract val required: Boolean + + class Required( + parser: Parser, + ) : Argument(parser) { + override val required: Boolean + get() = true + } + + class OrElse( + val other: T, + parser: Parser, + ) : Argument(parser) { + override val required: Boolean + get() = false + } + + class OrNull( + parser: Parser, + ) : Argument(parser) { + override val required: Boolean + get() = false + } + + class Variable( + parser: Parser, + ) : Argument(parser) { + override val required: Boolean + get() = false + } + + companion object { + fun string() = + Required(StringParser) + + fun stringOrElse(other: String) = + OrElse(other, StringParser) + + fun stringOrNull() = + OrNull(StringParser) + + fun strings() = + Variable(StringParser) + + fun int() = + Required(IntParser) + + fun intOrElse(other: Int) = + OrElse(other, IntParser) + + fun intOrNull() = + OrNull(IntParser) + + fun ints() = + Variable(IntParser) + + fun boolean() = + Required(BooleanParser) + + fun booleanOrElse(other: Boolean) = + OrElse(other, BooleanParser) + + fun booleanOrNull() = + OrNull(BooleanParser) + + fun booleans() = + Variable(BooleanParser) + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 550bf6d..36794ad 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -1,35 +1,22 @@ package com.infendro.cli.command -import com.infendro.cli.argument.Argument -import com.infendro.cli.exception.CliException import com.infendro.cli.exception.build.* -import com.infendro.cli.exception.run.UnknownCommandException -import com.infendro.cli.util.Regex.ARGUMENT_NAME -import com.infendro.cli.util.Regex.COMMAND_NAME +import com.infendro.cli.exception.run.CliException +import com.infendro.cli.util.Regex.COMMAND +import com.infendro.cli.util.Regex.OPTION class Command private constructor( val name: String?, val commands: List, + val options: List>, val arguments: List>, - private val block: Execution.() -> Unit, + internal val execute: Context.() -> Unit, ) { + val fallback: Boolean + get() = name == null + fun run(args: Array) = try { - val index = args.withIndex() - .firstOrNull { (_, value) -> value.startsWith("-") } - ?.index ?: args.size - - val cmd = args.slice(0..() private val arguments = mutableListOf>() - private lateinit var block: Execution.() -> Unit + private val options = mutableListOf>() + private lateinit var _execute: Context.() -> Unit private val nextIndex: Int get() = index + 1 fun command(name: String? = null, block: Builder.(Key) -> Unit) { - if (name != null && !name.matches(COMMAND_NAME)) - throw IllegalCommandNameException(name) + // validate + val last = commands.lastOrNull() + when { + last?.fallback == true -> throw CommandOrderException() + name?.matches(COMMAND) == false -> throw IllegalCommandNameException(name) + commands.any { it.name == name } -> throw DuplicateCommandException(name) + } - if (commands.any { it.name == name }) - throw DuplicateCommandException(name) - - val builder = Builder(nextIndex, name) - val key = Key(nextIndex) - builder.block(key) - val command = builder.build() - - commands.add(command) + commands += Builder(nextIndex, name).also { + val key = Key(nextIndex) + it.block(key) + }.build() } fun argument(argument: Argument<*>) { - for (name in argument.names) { - if (!name.matches(ARGUMENT_NAME)) - throw IllegalArgumentNameException(name) - - if (arguments.any { name in it.names }) - throw DuplicateArgumentException(name) + // validate + val last = arguments.lastOrNull() + when { + last is Argument.Variable -> throw ArgumentOrderException() + argument.required && last?.required == false -> throw ArgumentOrderException() } - arguments.add(argument) + arguments += argument } - fun arguments(vararg arguments: Argument<*>) = - arguments.forEach(::argument) + fun > T.register() = also(::argument) - fun execute(block: Execution.() -> Unit) { - if (this::block.isInitialized) + fun arguments(vararg arguments: Argument<*>) = arguments.forEach(::argument) + fun > Array.register() = also(::arguments) + + fun option(option: Option<*>) { + // validate + for (name in option.names) { + when { + !name.matches(OPTION) -> throw IllegalOptionNameException(name) + options.any { name in it.names } -> throw DuplicateOptionException(name) + } + } + + options += option + } + + fun > T.register() = also(::option) + + fun options(vararg options: Option<*>) = options.forEach(::option) + fun > Array.register() = also(::options) + + fun execute(block: Context.() -> Unit) { + if (::_execute.isInitialized) throw DuplicateExecuteException() - this.block = block + _execute = block } fun build(): Command { - if (!this::block.isInitialized) - throw NoExecuteException() + // validate + val last = commands.lastOrNull() + when { + last?.fallback == true && arguments.isNotEmpty() -> throw InvalidFallbackException() + } - return Command(name, commands, arguments, block) + if (!::_execute.isInitialized) + _execute = {} + + return Command(name, commands, options, arguments, _execute) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Context.kt b/src/commonMain/kotlin/com/infendro/cli/command/Context.kt new file mode 100644 index 0000000..9801d79 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/Context.kt @@ -0,0 +1,56 @@ +package com.infendro.cli.command + +import kotlin.reflect.KProperty + +class Context internal constructor( + val command: Command, + val commands: List, + val options: List, + val arguments: List, +) { + class Cmd( + val name: String, + ) + + class Arg( + val value: Any, + ) + + class Opt( + val name: String, + val value: Any, + ) + + internal fun execute() = (command.execute)() + + operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name + + private val Argument.index: Int + get() = command.arguments.indexOf(this) + + @Suppress("UNCHECKED_CAST") + private val Argument.value: T? + get() = arguments.getOrNull(index)?.value as? T + + @Suppress("UNCHECKED_CAST") + private val Argument.Variable.values: List + get() = arguments.drop(index).map { it.value as T } + + operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! + operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other + operator fun Argument.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value + operator fun Argument.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values + + @Suppress("UNCHECKED_CAST") + private val Option.value: T? + get() = options.firstOrNull { it.name in names }?.value as? T + + @Suppress("UNCHECKED_CAST") + private val Option.Variable.values: List + get() = options.filter { it.name in names }.map { it.value as T } + + operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! + operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other + operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value + operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Execution.kt b/src/commonMain/kotlin/com/infendro/cli/command/Execution.kt deleted file mode 100644 index 17a1449..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/command/Execution.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.infendro.cli.command - -import com.infendro.cli.argument.Argument -import com.infendro.cli.exception.run.DuplicateArgumentException -import com.infendro.cli.exception.run.MalformedArgumentException -import com.infendro.cli.exception.run.MissingArgumentException -import com.infendro.cli.exception.run.UnknownArgumentException -import kotlin.reflect.KProperty - -class Execution private constructor( - val commands: List, - val arguments: List, -) { - companion object { - fun from(command: Command, cmd: List, arg: List): Execution { - val commands = Cmd.from(cmd) - val arguments = Arg.from(command, arg) - return Execution(commands, arguments) - } - } - - class Cmd( - val name: String, - ) { - companion object { - fun from(cmd: List): List = cmd.map { Cmd(it) } - } - } - - class Arg( - val name: String, - val value: Any, - ) { - companion object { - fun from(command: Command, arg: List): List = buildList { - var i = 0 - while (i < arg.size) { - val dashes = arg[i].takeWhile { it == '-' }.count() - val trimmed = arg[i].substring(dashes) - - if (dashes !in 1..2 || trimmed.isEmpty()) - throw MalformedArgumentException(arg[i]) - - val (name, value) = when { - trimmed.contains('=') -> { - val (name, value) = trimmed.split('=', limit = 2) - name to value - } - - else -> { - val next = arg.getOrNull(i + 1) - if (next != null && !next.startsWith('-')) { - i++ - trimmed to next - } else { - trimmed to null - } - } - } - - val names = when { - dashes == 1 -> name.map { "$it" } - else -> listOf(name) - } - - for (name in names) { - val argument = command.arguments.firstOrNull { name in it.names } - ?: throw UnknownArgumentException(name) - - val value = argument.parser.parse(value) as Any - add(Arg(name, value)) - } - i++ - } - - for (argument in command.arguments) { - val count = count { it.name in argument.names } - when { - argument.required && count == 0 -> throw MissingArgumentException(argument.name) - count > 1 -> throw DuplicateArgumentException(argument.name) - } - } - } - } - } - - operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name - - @Suppress("UNCHECKED_CAST") - private fun Argument.getValue(): T? = arguments.firstOrNull { it.name in names }?.value as? T - operator fun Argument.Single.getValue(thisRef: Any?, property: KProperty<*>): T = getValue()!! - operator fun Argument.SingleOrElse.getValue(thisRef: Any?, property: KProperty<*>): T = getValue() ?: other - operator fun Argument.SingleOrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = getValue() -} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt new file mode 100644 index 0000000..8b342b5 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -0,0 +1,93 @@ +package com.infendro.cli.command + +import com.infendro.cli.exception.build.NoOptionNameException +import com.infendro.cli.parser.BooleanParser +import com.infendro.cli.parser.IntParser +import com.infendro.cli.parser.Parser +import com.infendro.cli.parser.StringParser + +sealed class Option( + val names: List, + val parser: Parser, +) { + val name: String + get() = names[0] + + abstract val required: Boolean + + init { + if (names.isEmpty()) + throw NoOptionNameException() + } + + class Required( + names: List, + parser: Parser, + ) : Option(names, parser) { + override val required: Boolean + get() = true + } + + class OrElse( + names: List, + val other: T, + parser: Parser, + ) : Option(names, parser) { + override val required: Boolean + get() = false + } + + class OrNull( + names: List, + parser: Parser, + ) : Option(names, parser) { + override val required: Boolean + get() = false + } + + class Variable( + names: List, + parser: Parser, + ) : Option(names, parser) { + override val required: Boolean + get() = false + } + + companion object { + fun string(vararg names: String) = + Required(names.asList(), StringParser) + + fun stringOrElse(vararg names: String, other: String) = + OrElse(names.asList(), other, StringParser) + + fun stringOrNull(vararg names: String) = + OrNull(names.asList(), StringParser) + + fun strings(vararg names: String) = + Variable(names.asList(), StringParser) + + fun int(vararg names: String) = + Required(names.asList(), IntParser) + + fun intOrElse(vararg names: String, other: Int) = + OrElse(names.asList(), other, IntParser) + + fun intOrNull(vararg names: String) = + OrNull(names.asList(), IntParser) + + fun ints(vararg names: String) = + Variable(names.asList(), IntParser) + + fun boolean(vararg names: String) = + Required(names.asList(), BooleanParser) + + fun booleanOrElse(vararg names: String, other: Boolean) = + OrElse(names.asList(), other, BooleanParser) + + fun booleanOrNull(vararg names: String) = + OrNull(names.asList(), BooleanParser) + + fun booleans(vararg names: String) = + Variable(names.asList(), BooleanParser) + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/command/Parser.kt new file mode 100644 index 0000000..9e95189 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/Parser.kt @@ -0,0 +1,120 @@ +package com.infendro.cli.command + +import com.infendro.cli.command.Context.* +import com.infendro.cli.exception.run.* + +internal object Parser { + fun parse(root: Command, args: Array): Context { + var command = root + val cmd = mutableListOf() + val arg = mutableListOf() + val opt = mutableListOf() + + var i = 0 + + // commands + while (i < args.size) { + val current = args[i] + + if (current.startsWith("-")) + break + + val c = command.commands.firstOrNull { it.name == current || it.fallback } + if (c == null) { + when { + command.arguments.isEmpty() -> throw UnknownCommandException(current) + else -> break + } + } + + command = c + cmd += Cmd(current) + + i++ + } + + // arguments and options + var endOfOptions = false + var argumentIndex = 0 + while (i < args.size) { + val dashes = args[i].takeWhile { it == '-' }.count() + val current = args[i].drop(dashes) + + when { + endOfOptions || dashes == 0 -> { + val argument = command.arguments.getOrNull(argumentIndex) + ?: throw TooManyArgumentsException() + + if (argument !is Argument.Variable) + argumentIndex++ + + val value = argument.parser.parse(current) as Any + arg += Arg(value) + } + + dashes in 1..2 -> { + if (dashes == 2 && current.isEmpty()) { + endOfOptions = true + i++ + continue + } + + val (name, value) = when { + current.contains('=') -> { + val (name, value) = current.split('=', limit = 2) + name to value + } + + else -> { + val next = args.getOrNull(i + 1) + if (next != null && !next.startsWith('-')) { + i++ + current to next + } else { + current 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 } + ?: throw UnknownOptionException(name) + + val value = option.parser.parse(value) as Any + opt += Opt(name, value) + } + } + + else -> { + when { + args[i].contains('=') -> { + val (option) = args[i].split('=', limit = 2) + throw MalformedOptionException(option) + } + else -> throw MalformedOptionException(args[i]) + } + } + } + + i++ + } + + if (arg.size < command.arguments.count { it is Argument.Required }) + throw MissingArgumentException() + + for (option in command.options) { + val count = opt.count { it.name in option.names } + when { + option is Option.Required && count == 0 -> throw MissingOptionException(option.name) + option !is Option.Variable && count > 1 -> throw DuplicateOptionException(option.name) + } + } + + return Context(command, cmd, opt, arg) + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt deleted file mode 100644 index 913de16..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception - -abstract class CliException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt new file mode 100644 index 0000000..f04bcf4 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class ArgumentOrderException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoExecuteException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt similarity index 50% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/NoExecuteException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt index 4fc891b..6ed8242 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoExecuteException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class NoExecuteException : Exception() +class CommandOrderException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateArgumentException.kt deleted file mode 100644 index 0d5d11d..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateArgumentException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateArgumentException( - val argument: String, -) : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalArgumentNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt similarity index 68% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalArgumentNameException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt index 3ad2f2d..96ca86d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalArgumentNameException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt @@ -1,5 +1,5 @@ package com.infendro.cli.exception.build -class IllegalArgumentNameException( +class DuplicateOptionException( val name: String, ) : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt new file mode 100644 index 0000000..88dcc73 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.build + +class IllegalOptionNameException( + val name: String, +) : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt new file mode 100644 index 0000000..07e66ee --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class InvalidFallbackException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoArgumentNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/NoArgumentNameException.kt deleted file mode 100644 index b8692ea..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoArgumentNameException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class NoArgumentNameException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt new file mode 100644 index 0000000..475aca6 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class NoOptionNameException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt new file mode 100644 index 0000000..c84ca0f --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.run + +abstract class CliException : Exception() \ No newline at end of file diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateArgumentException.kt deleted file mode 100644 index 1e33ac3..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateArgumentException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -class DuplicateArgumentException( - val argument: String, -) : CliException() { - override val message: String - get() = """duplicate argument "$argument"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt new file mode 100644 index 0000000..d6b84e7 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.exception.run + +class DuplicateOptionException( + val option: String, +) : CliException() { + override val message: String + get() = """duplicate option "$option"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedArgumentException.kt deleted file mode 100644 index 50e39f4..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedArgumentException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -class MalformedArgumentException( - val argument: String, -) : CliException() { - override val message: String - get() = """malformed argument "$argument"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt new file mode 100644 index 0000000..0e97e3d --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.exception.run + +class MalformedOptionException( + val option: String, +) : CliException() { + override val message: String + get() = """malformed option "$option"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt index 49fab78..fba26c7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt @@ -1,10 +1,6 @@ package com.infendro.cli.exception.run -import com.infendro.cli.exception.CliException - -class MissingArgumentException( - val argument: String, -) : CliException() { +class MissingArgumentException : CliException() { override val message: String - get() = """missing argument "$argument"""" + get() = """missing argument""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt new file mode 100644 index 0000000..317956f --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.exception.run + +class MissingOptionException( + val option: String, +) : CliException() { + override val message: String + get() = """missing option "$option"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt index b6966f0..2206a0b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt @@ -1,7 +1,5 @@ package com.infendro.cli.exception.run -import com.infendro.cli.exception.CliException - class ParseException( val text: String?, val type: String, diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt new file mode 100644 index 0000000..fe293ff --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.exception.run + +class TooManyArgumentsException : CliException() { + override val message: String + get() = """too many arguments""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownArgumentException.kt deleted file mode 100644 index 8cf98a2..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownArgumentException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -class UnknownArgumentException( - val argument: String, -) : CliException() { - override val message: String - get() = """unknown argument "$argument"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt index 795a923..5568c26 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt @@ -1,7 +1,5 @@ package com.infendro.cli.exception.run -import com.infendro.cli.exception.CliException - class UnknownCommandException( val command: String, ) : CliException() { diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt new file mode 100644 index 0000000..4fe2e48 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.exception.run + +class UnknownOptionException( + val option: String, +) : CliException() { + override val message: String + get() = """unknown option "$option"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt new file mode 100644 index 0000000..f60da95 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object BooleanParser : Parser { + override fun parse(text: String?): Boolean = when (text) { + "true", "t", null -> true + "false", "f" -> false + else -> throw ParseException(text, "Boolean") + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt new file mode 100644 index 0000000..1ea0e77 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object IntParser : Parser { + override fun parse(text: String?): Int = try { + text!!.toInt() + } catch (_: Exception) { + throw ParseException(text, "Int") + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt new file mode 100644 index 0000000..44626f8 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.parser + +interface Parser { + fun parse(text: String?): T +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt new file mode 100644 index 0000000..3387c03 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -0,0 +1,7 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object StringParser : Parser { + override fun parse(text: String?): String = text ?: throw ParseException(text, "String") +} diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt index b7cd051..aebe5f6 100644 --- a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt +++ b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt @@ -1,6 +1,8 @@ package com.infendro.cli.util internal object Regex { - val COMMAND_NAME = Regex("""[a-z]+(?:-[a-z]+)*""") - val ARGUMENT_NAME = Regex("""[a-z]+(?:[\-.][a-z]+)*""") + private const val L = "[a-zA-Z]" + private const val AN = """[a-zA-Z0-9]""" + val COMMAND = Regex("""${L}${AN}*(?:-${AN}+)*""") + val OPTION = Regex("""${L}${AN}*(?:[\-.]${AN}+)*""") } From 06a2d1bdce84224de1a4f9c43a070ae3316e867c Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 31 Dec 2025 18:51:16 +0100 Subject: [PATCH 02/20] implement EnumParser --- .../kotlin/com/infendro/cli/command/Argument.kt | 16 +++++++++++++--- .../kotlin/com/infendro/cli/command/Option.kt | 16 +++++++++++++--- .../com/infendro/cli/parser/EnumParser.kt | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 53a4ec7..3175660 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -1,9 +1,7 @@ package com.infendro.cli.command -import com.infendro.cli.parser.BooleanParser -import com.infendro.cli.parser.IntParser +import com.infendro.cli.parser.* import com.infendro.cli.parser.Parser -import com.infendro.cli.parser.StringParser sealed class Argument( val parser: Parser, @@ -75,5 +73,17 @@ sealed class Argument( fun booleans() = Variable(BooleanParser) + + inline fun > enum() = + Required(enumParser()) + + inline fun > enumOrElse(other: T) = + OrElse(other, enumParser()) + + inline fun > enumOrNull() = + OrNull(enumParser()) + + inline fun > enums() = + Variable(enumParser()) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index 8b342b5..23f9af6 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -1,10 +1,8 @@ package com.infendro.cli.command import com.infendro.cli.exception.build.NoOptionNameException -import com.infendro.cli.parser.BooleanParser -import com.infendro.cli.parser.IntParser +import com.infendro.cli.parser.* import com.infendro.cli.parser.Parser -import com.infendro.cli.parser.StringParser sealed class Option( val names: List, @@ -89,5 +87,17 @@ sealed class Option( fun booleans(vararg names: String) = Variable(names.asList(), BooleanParser) + + inline fun > enum(vararg names: String) = + Required(names.asList(), enumParser()) + + inline fun > enumOrElse(vararg names: String, other: T) = + OrElse(names.asList(), other, enumParser()) + + inline fun > enumOrNull(vararg names: String) = + OrNull(names.asList(), enumParser()) + + inline fun > enums(vararg names: String) = + Variable(names.asList(), enumParser()) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt new file mode 100644 index 0000000..df04cbf --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -0,0 +1,17 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException +import kotlin.enums.enumEntries + +class EnumParser>( + private val name: String, + private val values: List, +) : Parser { + override fun parse(text: String?): T = try { + values.first { it.name == text } + } catch (_: Exception) { + throw ParseException(text, name) + } +} + +inline fun > enumParser() = EnumParser(T::class.simpleName!!, enumEntries()) From 0dc064b8cfb0752cc3c57e3eae280f84b0655377 Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 31 Dec 2025 22:45:43 +0100 Subject: [PATCH 03/20] improve parsing and flag Options --- .../com/infendro/cli/command/Argument.kt | 11 +-- .../com/infendro/cli/command/Command.kt | 28 +++--- .../com/infendro/cli/command/Context.kt | 56 ----------- .../kotlin/com/infendro/cli/command/Option.kt | 94 ++++++++++++------- .../infendro/cli/command/context/Context.kt | 59 ++++++++++++ .../cli/command/{ => context}/Parser.kt | 18 +++- .../build/MissingOptionNameException.kt | 3 + .../exception/build/NoOptionNameException.kt | 3 - .../run/MissingOptionValueException.kt | 8 ++ .../com/infendro/cli/parser/BooleanParser.kt | 4 +- .../com/infendro/cli/parser/EnumParser.kt | 2 +- .../com/infendro/cli/parser/IntParser.kt | 4 +- .../kotlin/com/infendro/cli/parser/Parser.kt | 2 +- .../com/infendro/cli/parser/StringParser.kt | 4 +- .../kotlin/com/infendro/cli/util/Regex.kt | 6 +- 15 files changed, 171 insertions(+), 131 deletions(-) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/command/Context.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt rename src/commonMain/kotlin/com/infendro/cli/command/{ => context}/Parser.kt (85%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 3175660..75c3b41 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -1,21 +1,20 @@ package com.infendro.cli.command import com.infendro.cli.parser.* -import com.infendro.cli.parser.Parser -sealed class Argument( +sealed class Argument( val parser: Parser, ) { abstract val required: Boolean - class Required( + class Required( parser: Parser, ) : Argument(parser) { override val required: Boolean get() = true } - class OrElse( + class OrElse( val other: T, parser: Parser, ) : Argument(parser) { @@ -23,14 +22,14 @@ sealed class Argument( get() = false } - class OrNull( + class OrNull( parser: Parser, ) : Argument(parser) { override val required: Boolean get() = false } - class Variable( + class Variable( parser: Parser, ) : Argument(parser) { override 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 36794ad..877c916 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -1,5 +1,7 @@ package com.infendro.cli.command +import com.infendro.cli.command.context.Context +import com.infendro.cli.command.context.Parser import com.infendro.cli.exception.build.* import com.infendro.cli.exception.run.CliException import com.infendro.cli.util.Regex.COMMAND @@ -22,7 +24,7 @@ class Command private constructor( } class Builder internal constructor( - private val index: Int, + private val level: Int, private val name: String?, ) { private val commands = mutableListOf() @@ -30,22 +32,19 @@ class Command private constructor( private val options = mutableListOf>() private lateinit var _execute: Context.() -> Unit - private val nextIndex: Int - get() = index + 1 + private val key: Key + get() = Key(level) fun command(name: String? = null, block: Builder.(Key) -> Unit) { // validate val last = commands.lastOrNull() when { - last?.fallback == true -> throw CommandOrderException() - name?.matches(COMMAND) == false -> throw IllegalCommandNameException(name) + last != null && last.fallback -> throw CommandOrderException() + name != null && !name.matches(COMMAND) -> throw IllegalCommandNameException(name) commands.any { it.name == name } -> throw DuplicateCommandException(name) } - commands += Builder(nextIndex, name).also { - val key = Key(nextIndex) - it.block(key) - }.build() + commands += Builder(level + 1, name).also { it.block(it.key) }.build() } fun argument(argument: Argument<*>) { @@ -53,7 +52,7 @@ class Command private constructor( val last = arguments.lastOrNull() when { last is Argument.Variable -> throw ArgumentOrderException() - argument.required && last?.required == false -> throw ArgumentOrderException() + argument.required && last != null && !last.required -> throw ArgumentOrderException() } arguments += argument @@ -92,7 +91,7 @@ class Command private constructor( // validate val last = commands.lastOrNull() when { - last?.fallback == true && arguments.isNotEmpty() -> throw InvalidFallbackException() + last != null && last.fallback && arguments.isNotEmpty() -> throw InvalidFallbackException() } if (!::_execute.isInitialized) @@ -107,8 +106,5 @@ class Command private constructor( ) } -fun cli(block: Command.Builder.() -> Unit): Command { - val builder = Command.Builder(-1, null) - builder.block() - return builder.build() -} +fun cli(block: Command.Builder.() -> Unit) = + Command.Builder(-1, null).also(block).build() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Context.kt b/src/commonMain/kotlin/com/infendro/cli/command/Context.kt deleted file mode 100644 index 9801d79..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/command/Context.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.infendro.cli.command - -import kotlin.reflect.KProperty - -class Context internal constructor( - val command: Command, - val commands: List, - val options: List, - val arguments: List, -) { - class Cmd( - val name: String, - ) - - class Arg( - val value: Any, - ) - - class Opt( - val name: String, - val value: Any, - ) - - internal fun execute() = (command.execute)() - - operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name - - private val Argument.index: Int - get() = command.arguments.indexOf(this) - - @Suppress("UNCHECKED_CAST") - private val Argument.value: T? - get() = arguments.getOrNull(index)?.value as? T - - @Suppress("UNCHECKED_CAST") - private val Argument.Variable.values: List - get() = arguments.drop(index).map { it.value as T } - - operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! - operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other - operator fun Argument.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value - operator fun Argument.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values - - @Suppress("UNCHECKED_CAST") - private val Option.value: T? - get() = options.firstOrNull { it.name in names }?.value as? T - - @Suppress("UNCHECKED_CAST") - private val Option.Variable.values: List - get() = options.filter { it.name in names }.map { it.value as T } - - operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! - operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other - operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value - operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values -} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index 23f9af6..37e6eac 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -1,13 +1,16 @@ package com.infendro.cli.command -import com.infendro.cli.exception.build.NoOptionNameException +import com.infendro.cli.exception.build.MissingOptionNameException import com.infendro.cli.parser.* -import com.infendro.cli.parser.Parser -sealed class Option( - val names: List, +sealed class Option( val parser: Parser, + val fallback: T?, + val names: List, ) { + val flag: Boolean + get() = fallback != null + val name: String get() = names[0] @@ -15,89 +18,114 @@ sealed class Option( init { if (names.isEmpty()) - throw NoOptionNameException() + throw MissingOptionNameException() } - class Required( - names: List, + class Required( parser: Parser, - ) : Option(names, parser) { + fallback: T?, + names: List, + ) : Option(parser, fallback, names) { + constructor( + parser: Parser, + names: List, + ) : this(parser, null, names) + override val required: Boolean get() = true } - class OrElse( + class OrElse( + parser: Parser, + fallback: T?, names: List, val other: T, - parser: Parser, - ) : Option(names, parser) { + ) : Option(parser, fallback, names) { + constructor( + parser: Parser, + names: List, + other: T, + ) : this(parser, null, names, other) + override val required: Boolean get() = false } - class OrNull( - names: List, + class OrNull( parser: Parser, - ) : Option(names, parser) { + fallback: T?, + names: List, + ) : Option(parser, fallback, names) { + constructor( + parser: Parser, + names: List, + ) : this(parser, null, names) + override val required: Boolean get() = false } - class Variable( - names: List, + class Variable( parser: Parser, - ) : Option(names, parser) { + fallback: T?, + names: List, + ) : Option(parser, fallback, names) { + constructor( + parser: Parser, + names: List, + ) : this(parser, null, names) + override val required: Boolean get() = false } companion object { fun string(vararg names: String) = - Required(names.asList(), StringParser) + Required(StringParser, names.asList()) fun stringOrElse(vararg names: String, other: String) = - OrElse(names.asList(), other, StringParser) + OrElse(StringParser, names.asList(), other) fun stringOrNull(vararg names: String) = - OrNull(names.asList(), StringParser) + OrNull(StringParser, names.asList()) fun strings(vararg names: String) = - Variable(names.asList(), StringParser) + Variable(StringParser, names.asList()) fun int(vararg names: String) = - Required(names.asList(), IntParser) + Required(IntParser, names.asList()) fun intOrElse(vararg names: String, other: Int) = - OrElse(names.asList(), other, IntParser) + OrElse(IntParser, names.asList(), other) fun intOrNull(vararg names: String) = - OrNull(names.asList(), IntParser) + OrNull(IntParser, names.asList()) fun ints(vararg names: String) = - Variable(names.asList(), IntParser) + Variable(IntParser, names.asList()) fun boolean(vararg names: String) = - Required(names.asList(), BooleanParser) + Required(BooleanParser, true, names.asList()) fun booleanOrElse(vararg names: String, other: Boolean) = - OrElse(names.asList(), other, BooleanParser) + OrElse(BooleanParser, true, names.asList(), other) fun booleanOrNull(vararg names: String) = - OrNull(names.asList(), BooleanParser) + OrNull(BooleanParser, true, names.asList()) fun booleans(vararg names: String) = - Variable(names.asList(), BooleanParser) + Variable(BooleanParser, true, names.asList()) inline fun > enum(vararg names: String) = - Required(names.asList(), enumParser()) + Required(enumParser(), names.asList()) inline fun > enumOrElse(vararg names: String, other: T) = - OrElse(names.asList(), other, enumParser()) + OrElse(enumParser(), names.asList(), other) inline fun > enumOrNull(vararg names: String) = - OrNull(names.asList(), enumParser()) + OrNull(enumParser(), names.asList()) inline fun > enums(vararg names: String) = - Variable(names.asList(), enumParser()) + Variable(enumParser(), names.asList()) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt new file mode 100644 index 0000000..037d2e4 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -0,0 +1,59 @@ +package com.infendro.cli.command.context + +import com.infendro.cli.command.Argument +import com.infendro.cli.command.Command +import com.infendro.cli.command.Option +import kotlin.reflect.KProperty + +class Context internal constructor( + val command: Command, + val commands: List, + val options: List, + val arguments: List, +) { + class Cmd( + val name: String, + ) + + class Arg( + val value: Any, + ) + + class Opt( + val name: String, + val value: Any, + ) + + internal fun execute() = (command.execute)() + + operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name + + private val Argument.index: Int + get() = command.arguments.indexOf(this) + + @Suppress("UNCHECKED_CAST") + private val Argument.value: T? + get() = arguments.getOrNull(index)?.value as? T + + @Suppress("UNCHECKED_CAST") + private val Argument.Variable.values: List + get() = arguments.drop(index).map { it.value as T } + + operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! + operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other + operator fun Argument.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value + operator fun Argument.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values + + @Suppress("UNCHECKED_CAST") + private val Option.value: T? + get() = options.firstOrNull { it.name in names }?.value as? T + + @Suppress("UNCHECKED_CAST") + private val Option.Variable.values: List + get() = options.filter { it.name in names }.map { it.value as T } + + operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! + operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other + operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value + operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt similarity index 85% rename from src/commonMain/kotlin/com/infendro/cli/command/Parser.kt rename to src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt index 9e95189..d6a8e7b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt @@ -1,6 +1,9 @@ -package com.infendro.cli.command +package com.infendro.cli.command.context -import com.infendro.cli.command.Context.* +import com.infendro.cli.command.Argument +import com.infendro.cli.command.Command +import com.infendro.cli.command.Option +import com.infendro.cli.command.context.Context.* import com.infendro.cli.exception.run.* internal object Parser { @@ -41,6 +44,7 @@ internal object Parser { val current = args[i].drop(dashes) when { + // argument endOfOptions || dashes == 0 -> { val argument = command.arguments.getOrNull(argumentIndex) ?: throw TooManyArgumentsException() @@ -52,6 +56,7 @@ internal object Parser { arg += Arg(value) } + // option dashes in 1..2 -> { if (dashes == 2 && current.isEmpty()) { endOfOptions = true @@ -59,7 +64,7 @@ internal object Parser { continue } - val (name, value) = when { + val (name, text) = when { current.contains('=') -> { val (name, value) = current.split('=', limit = 2) name to value @@ -85,11 +90,16 @@ internal object Parser { val option = command.options.firstOrNull { name in it.names } ?: throw UnknownOptionException(name) - val value = option.parser.parse(value) as Any + val value = when { + text != null -> option.parser.parse(text) + option.flag -> option.fallback!! + else -> throw MissingOptionValueException(name) + } opt += Opt(name, value) } } + // malformed option else -> { when { args[i].contains('=') -> { diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt new file mode 100644 index 0000000..bd969f8 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class MissingOptionNameException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt deleted file mode 100644 index 475aca6..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoOptionNameException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class NoOptionNameException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt new file mode 100644 index 0000000..24b2cb7 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.exception.run + +class MissingOptionValueException( + val option: String, +) : CliException() { + override val message: String + get() = """no value provided for option "$option"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt index f60da95..ce894ab 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -3,8 +3,8 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object BooleanParser : Parser { - override fun parse(text: String?): Boolean = when (text) { - "true", "t", null -> true + override fun parse(text: String): Boolean = when (text) { + "true", "t" -> true "false", "f" -> false else -> throw ParseException(text, "Boolean") } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt index df04cbf..b4247a4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -7,7 +7,7 @@ class EnumParser>( private val name: String, private val values: List, ) : Parser { - override fun parse(text: String?): T = try { + override fun parse(text: String): T = try { values.first { it.name == text } } catch (_: Exception) { throw ParseException(text, name) diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt index 1ea0e77..750920a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -3,8 +3,8 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object IntParser : Parser { - override fun parse(text: String?): Int = try { - text!!.toInt() + override fun parse(text: String): Int = try { + text.toInt() } catch (_: Exception) { throw ParseException(text, "Int") } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt index 44626f8..fc15d53 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -1,5 +1,5 @@ package com.infendro.cli.parser interface Parser { - fun parse(text: String?): T + fun parse(text: String): T } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt index 3387c03..17248d5 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -1,7 +1,5 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException - object StringParser : Parser { - override fun parse(text: String?): String = text ?: throw ParseException(text, "String") + override fun parse(text: String): String = text } diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt index aebe5f6..1eeb52c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt +++ b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt @@ -1,8 +1,6 @@ package com.infendro.cli.util internal object Regex { - private const val L = "[a-zA-Z]" - private const val AN = """[a-zA-Z0-9]""" - val COMMAND = Regex("""${L}${AN}*(?:-${AN}+)*""") - val OPTION = Regex("""${L}${AN}*(?:[\-.]${AN}+)*""") + val COMMAND = Regex("""[a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)*""") + val OPTION = Regex("""[a-zA-Z][a-zA-Z0-9]*(?:[\-.][a-zA-Z0-9]+)*""") } From 7b9e39f9d0e2aec35b00571b4ff37835f57ec377 Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 1 Jan 2026 00:25:56 +0100 Subject: [PATCH 04/20] implement more parsers --- .../com/infendro/cli/command/Argument.kt | 58 +++++++++++++++++-- .../kotlin/com/infendro/cli/command/Option.kt | 48 +++++++++++++++ .../com/infendro/cli/parser/DoubleParser.kt | 11 ++++ .../com/infendro/cli/parser/FloatParser.kt | 11 ++++ .../com/infendro/cli/parser/LongParser.kt | 11 ++++ .../com/infendro/cli/parser/RegexParser.kt | 5 ++ .../com/infendro/cli/parser/StringParser.kt | 2 +- 7 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 75c3b41..c942c2d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -15,8 +15,8 @@ sealed class Argument( } class OrElse( - val other: T, parser: Parser, + val other: T, ) : Argument(parser) { override val required: Boolean get() = false @@ -41,7 +41,7 @@ sealed class Argument( Required(StringParser) fun stringOrElse(other: String) = - OrElse(other, StringParser) + OrElse(StringParser, other) fun stringOrNull() = OrNull(StringParser) @@ -53,7 +53,7 @@ sealed class Argument( Required(IntParser) fun intOrElse(other: Int) = - OrElse(other, IntParser) + OrElse(IntParser, other) fun intOrNull() = OrNull(IntParser) @@ -61,11 +61,47 @@ sealed class Argument( fun ints() = Variable(IntParser) + fun long() = + Required(LongParser) + + fun longOrElse(other: Long) = + OrElse(LongParser, other) + + fun longOrNull() = + OrNull(LongParser) + + fun longs() = + Variable(LongParser) + + fun float() = + Required(FloatParser) + + fun floatOrElse(other: Float) = + OrElse(FloatParser, other) + + fun floatOrNull() = + OrNull(FloatParser) + + fun floats() = + Variable(FloatParser) + + fun double() = + Required(DoubleParser) + + fun doubleOrElse(other: Double) = + OrElse(DoubleParser, other) + + fun doubleOrNull() = + OrNull(DoubleParser) + + fun doubles() = + Variable(DoubleParser) + fun boolean() = Required(BooleanParser) fun booleanOrElse(other: Boolean) = - OrElse(other, BooleanParser) + OrElse(BooleanParser, other) fun booleanOrNull() = OrNull(BooleanParser) @@ -77,12 +113,24 @@ sealed class Argument( Required(enumParser()) inline fun > enumOrElse(other: T) = - OrElse(other, enumParser()) + OrElse(enumParser(), other) inline fun > enumOrNull() = OrNull(enumParser()) inline fun > enums() = Variable(enumParser()) + + fun regex() = + Required(RegexParser) + + fun regexOrElse(other: Regex) = + OrElse(RegexParser, other) + + fun regexOrNull() = + OrNull(RegexParser) + + fun regexs() = + Variable(RegexParser) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index 37e6eac..bea4324 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -104,6 +104,42 @@ sealed class Option( fun ints(vararg names: String) = Variable(IntParser, names.asList()) + fun long(vararg names: String) = + Required(LongParser, names.asList()) + + fun longOrElse(vararg names: String, other: Long) = + OrElse(LongParser, names.asList(), other) + + fun longOrNull(vararg names: String) = + OrNull(LongParser, names.asList()) + + fun longs(vararg names: String) = + Variable(LongParser, names.asList()) + + fun float(vararg names: String) = + Required(FloatParser, names.asList()) + + fun floatOrElse(vararg names: String, other: Float) = + OrElse(FloatParser, names.asList(), other) + + fun floatOrNull(vararg names: String) = + OrNull(FloatParser, names.asList()) + + fun floats(vararg names: String) = + Variable(FloatParser, names.asList()) + + fun double(vararg names: String) = + Required(DoubleParser, names.asList()) + + fun doubleOrElse(vararg names: String, other: Double) = + OrElse(DoubleParser, names.asList(), other) + + fun doubleOrNull(vararg names: String) = + OrNull(DoubleParser, names.asList()) + + fun doubles(vararg names: String) = + Variable(DoubleParser, names.asList()) + fun boolean(vararg names: String) = Required(BooleanParser, true, names.asList()) @@ -127,5 +163,17 @@ sealed class Option( inline fun > enums(vararg names: String) = Variable(enumParser(), names.asList()) + + fun regex(vararg names: String) = + Required(RegexParser, names.asList()) + + fun regexOrElse(vararg names: String, other: Regex) = + OrElse(RegexParser, names.asList(), other) + + fun regexOrNull(vararg names: String) = + OrNull(RegexParser, names.asList()) + + fun regexs(vararg names: String) = + Variable(RegexParser, names.asList()) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt new file mode 100644 index 0000000..0efcf27 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object DoubleParser : Parser { + override fun parse(text: String): Double = try { + text.toDouble() + } catch (_: Exception) { + throw ParseException(text, "Double") + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt new file mode 100644 index 0000000..28434c3 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object FloatParser : Parser { + override fun parse(text: String): Float = try { + text.toFloat() + } catch (_: Exception) { + throw ParseException(text, "Float") + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt new file mode 100644 index 0000000..8072f83 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.parser + +import com.infendro.cli.exception.run.ParseException + +object LongParser : Parser { + override fun parse(text: String): Long = try { + text.toLong() + } catch (_: Exception) { + throw ParseException(text, "Long") + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt new file mode 100644 index 0000000..098c4c1 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.parser + +object RegexParser : Parser { + override fun parse(text: String) = Regex(text) +} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt index 17248d5..c3c6ddc 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -1,5 +1,5 @@ package com.infendro.cli.parser object StringParser : Parser { - override fun parse(text: String): String = text + override fun parse(text: String) = text } From 6779e28792133a6ac9bbeceaebcd53c5b027e084 Mon Sep 17 00:00:00 2001 From: Infendro Date: Mon, 5 Jan 2026 15:56:14 +0100 Subject: [PATCH 05/20] bump version to 1.4.0 --- README.md | 61 ++++++++++--------- build.gradle.kts | 2 +- .../com/infendro/cli/command/Command.kt | 4 +- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index fdcbe38..9f11455 100644 --- a/README.md +++ b/README.md @@ -21,46 +21,47 @@ repositories { } dependencies { - implementation("com.infendro:cli:1.3.1") + implementation("com.infendro:cli:1.4.0") } ``` ## Usage ```kotlin -fun main(args: Array) { - // declare root command - cli { +fun main(args: Array) = cli { + execute { + // define execution of command (e.g., display help) + } + + // declare command "greet" + command("greet") { + // declare arguments + val speakersArg = Argument.strings().register() + + // declare options + val greetingOpt = Option.stringOrElse("greeting", "greet", "g", other = "Hello").register() + val nameOpt = Option.stringOrNull("name", "n").register() + execute { - // execution of root command (e.g., display help) + // retrieve the arguments and options + val speakers by speakersArg + val greeting by greetingOpt + val name by nameOpt + + println("${speakers.joinToString()}: $greeting ${name ?: "World"}!") } - // declare command "test" - command("test") { - // declare arguments - val greetingArg = stringOrElse("greeting", "greet", "g", other = "Hello") - val nameArg = stringOrNull("name", "n") + // declare more commands... + } - execute { - // retrieve the arguments - val greeting by greetingArg - val name by nameArg + // declare fallback command + command { valueCmd -> + execute { + // retrieve the value used for the fallback command + val value by valueCmd - println("$greeting ${name ?: "World"}!") - } - - // declare more commands... + println("""command "$value" executed!""") } - - // declare wildcard command - command { wildcard -> - execute { - // retrieve the value used for the wildcard command - val value by wildcard - - println("command $value executed!") - } - } - }.run(args) -} + } +}.run(args) ``` diff --git a/build.gradle.kts b/build.gradle.kts index df76f3b..9df02c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ import com.infendro.plugin.infendro import com.infendro.plugin.token group = "com.infendro" -version = "1.3.1" +version = "1.4.0" plugins { alias(libs.plugins.infendro) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 877c916..b9b32c4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -10,8 +10,8 @@ import com.infendro.cli.util.Regex.OPTION class Command private constructor( val name: String?, val commands: List, - val options: List>, val arguments: List>, + val options: List>, internal val execute: Context.() -> Unit, ) { val fallback: Boolean @@ -97,7 +97,7 @@ class Command private constructor( if (!::_execute.isInitialized) _execute = {} - return Command(name, commands, options, arguments, _execute) + return Command(name, commands, arguments, options, _execute) } } From 86409e9f72430e7106e3e64785106f615d4aee5e Mon Sep 17 00:00:00 2001 From: Infendro Date: Mon, 5 Jan 2026 16:44:07 +0100 Subject: [PATCH 06/20] improve argument creation, improve fallback command --- README.md | 10 +-- .../com/infendro/cli/command/Argument.kt | 76 +------------------ .../com/infendro/cli/command/Command.kt | 16 +++- .../kotlin/com/infendro/cli/command/Option.kt | 76 +------------------ 4 files changed, 26 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 9f11455..3d462be 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ fun main(args: Array) = cli { // declare command "greet" command("greet") { // declare arguments - val speakersArg = Argument.strings().register() + val speakersArg = Argument.string().variable().register() // declare options - val greetingOpt = Option.stringOrElse("greeting", "greet", "g", other = "Hello").register() - val nameOpt = Option.stringOrNull("name", "n").register() + val greetingOpt = Option.string("greeting", "greet", "g").orElse("Hello").register() + val nameOpt = Option.string("name", "n").orNull().register() execute { // retrieve the arguments and options @@ -55,10 +55,10 @@ fun main(args: Array) = cli { } // declare fallback command - command { valueCmd -> + fallback { valueArg -> execute { // retrieve the value used for the fallback command - val value by valueCmd + val value by valueArg println("""command "$value" executed!""") } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index c942c2d..7b6c945 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -12,6 +12,10 @@ sealed class Argument( ) : Argument(parser) { override val required: Boolean get() = true + + fun orElse(other: T) = OrElse(parser, other) + fun orNull() = OrNull(parser) + fun variable() = Variable(parser) } class OrElse( @@ -40,97 +44,25 @@ sealed class Argument( fun string() = Required(StringParser) - fun stringOrElse(other: String) = - OrElse(StringParser, other) - - fun stringOrNull() = - OrNull(StringParser) - - fun strings() = - Variable(StringParser) - fun int() = Required(IntParser) - fun intOrElse(other: Int) = - OrElse(IntParser, other) - - fun intOrNull() = - OrNull(IntParser) - - fun ints() = - Variable(IntParser) - fun long() = Required(LongParser) - fun longOrElse(other: Long) = - OrElse(LongParser, other) - - fun longOrNull() = - OrNull(LongParser) - - fun longs() = - Variable(LongParser) - fun float() = Required(FloatParser) - fun floatOrElse(other: Float) = - OrElse(FloatParser, other) - - fun floatOrNull() = - OrNull(FloatParser) - - fun floats() = - Variable(FloatParser) - fun double() = Required(DoubleParser) - fun doubleOrElse(other: Double) = - OrElse(DoubleParser, other) - - fun doubleOrNull() = - OrNull(DoubleParser) - - fun doubles() = - Variable(DoubleParser) - fun boolean() = Required(BooleanParser) - fun booleanOrElse(other: Boolean) = - OrElse(BooleanParser, other) - - fun booleanOrNull() = - OrNull(BooleanParser) - - fun booleans() = - Variable(BooleanParser) - inline fun > enum() = Required(enumParser()) - inline fun > enumOrElse(other: T) = - OrElse(enumParser(), other) - - inline fun > enumOrNull() = - OrNull(enumParser()) - - inline fun > enums() = - Variable(enumParser()) - fun regex() = Required(RegexParser) - - fun regexOrElse(other: Regex) = - OrElse(RegexParser, other) - - fun regexOrNull() = - OrNull(RegexParser) - - fun regexs() = - Variable(RegexParser) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index b9b32c4..3936b1e 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -35,16 +35,26 @@ class Command private constructor( private val key: Key get() = Key(level) - fun command(name: String? = null, block: Builder.(Key) -> Unit) { + fun command(name: String, block: Builder.() -> Unit) { // validate val last = commands.lastOrNull() when { last != null && last.fallback -> throw CommandOrderException() - name != null && !name.matches(COMMAND) -> throw IllegalCommandNameException(name) + !name.matches(COMMAND) -> throw IllegalCommandNameException(name) commands.any { it.name == name } -> throw DuplicateCommandException(name) } - commands += Builder(level + 1, name).also { it.block(it.key) }.build() + commands += Builder(level + 1, name).also(block).build() + } + + fun fallback(block: Builder.(Key) -> Unit) { + // validate + val last = commands.lastOrNull() + when { + last != null && last.fallback -> throw CommandOrderException() + } + + commands += Builder(level + 1, null).also { it.block(it.key) }.build() } fun argument(argument: Argument<*>) { diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index bea4324..ab39039 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -33,6 +33,10 @@ sealed class Option( override val required: Boolean get() = true + + fun orElse(other: T) = OrElse(parser, fallback, names, other) + fun orNull() = OrNull(parser, fallback, names) + fun variable() = Variable(parser, fallback, names) } class OrElse( @@ -83,97 +87,25 @@ sealed class Option( fun string(vararg names: String) = Required(StringParser, names.asList()) - fun stringOrElse(vararg names: String, other: String) = - OrElse(StringParser, names.asList(), other) - - fun stringOrNull(vararg names: String) = - OrNull(StringParser, names.asList()) - - fun strings(vararg names: String) = - Variable(StringParser, names.asList()) - fun int(vararg names: String) = Required(IntParser, names.asList()) - fun intOrElse(vararg names: String, other: Int) = - OrElse(IntParser, names.asList(), other) - - fun intOrNull(vararg names: String) = - OrNull(IntParser, names.asList()) - - fun ints(vararg names: String) = - Variable(IntParser, names.asList()) - fun long(vararg names: String) = Required(LongParser, names.asList()) - fun longOrElse(vararg names: String, other: Long) = - OrElse(LongParser, names.asList(), other) - - fun longOrNull(vararg names: String) = - OrNull(LongParser, names.asList()) - - fun longs(vararg names: String) = - Variable(LongParser, names.asList()) - fun float(vararg names: String) = Required(FloatParser, names.asList()) - fun floatOrElse(vararg names: String, other: Float) = - OrElse(FloatParser, names.asList(), other) - - fun floatOrNull(vararg names: String) = - OrNull(FloatParser, names.asList()) - - fun floats(vararg names: String) = - Variable(FloatParser, names.asList()) - fun double(vararg names: String) = Required(DoubleParser, names.asList()) - fun doubleOrElse(vararg names: String, other: Double) = - OrElse(DoubleParser, names.asList(), other) - - fun doubleOrNull(vararg names: String) = - OrNull(DoubleParser, names.asList()) - - fun doubles(vararg names: String) = - Variable(DoubleParser, names.asList()) - fun boolean(vararg names: String) = Required(BooleanParser, true, names.asList()) - fun booleanOrElse(vararg names: String, other: Boolean) = - OrElse(BooleanParser, true, names.asList(), other) - - fun booleanOrNull(vararg names: String) = - OrNull(BooleanParser, true, names.asList()) - - fun booleans(vararg names: String) = - Variable(BooleanParser, true, names.asList()) - inline fun > enum(vararg names: String) = Required(enumParser(), names.asList()) - inline fun > enumOrElse(vararg names: String, other: T) = - OrElse(enumParser(), names.asList(), other) - - inline fun > enumOrNull(vararg names: String) = - OrNull(enumParser(), names.asList()) - - inline fun > enums(vararg names: String) = - Variable(enumParser(), names.asList()) - fun regex(vararg names: String) = Required(RegexParser, names.asList()) - - fun regexOrElse(vararg names: String, other: Regex) = - OrElse(RegexParser, names.asList(), other) - - fun regexOrNull(vararg names: String) = - OrNull(RegexParser, names.asList()) - - fun regexs(vararg names: String) = - Variable(RegexParser, names.asList()) } } From 0a8e51c7aba52a82c2863459890f8c4f1b92d12c Mon Sep 17 00:00:00 2001 From: Infendro Date: Tue, 6 Jan 2026 11:12:45 +0100 Subject: [PATCH 07/20] improve argument/option declaration --- src/commonMain/kotlin/com/infendro/cli/Dsl.kt | 4 +++ .../com/infendro/cli/command/Argument.kt | 33 ++++++----------- .../com/infendro/cli/command/Command.kt | 15 ++++---- .../kotlin/com/infendro/cli/command/Option.kt | 36 ++++++++----------- .../infendro/cli/command/context/Context.kt | 2 ++ .../cli/exception/run/ExecuteException.kt | 5 +++ .../com/infendro/cli/parser/DoubleParser.kt | 7 ++-- .../com/infendro/cli/parser/EnumParser.kt | 7 ++-- .../com/infendro/cli/parser/FloatParser.kt | 7 ++-- .../com/infendro/cli/parser/IntParser.kt | 7 ++-- .../com/infendro/cli/parser/LongParser.kt | 7 ++-- 11 files changed, 55 insertions(+), 75 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/Dsl.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/Dsl.kt b/src/commonMain/kotlin/com/infendro/cli/Dsl.kt new file mode 100644 index 0000000..5705e9a --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/Dsl.kt @@ -0,0 +1,4 @@ +package com.infendro.cli + +@DslMarker +internal annotation class Dsl() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 7b6c945..6a1ad1b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -41,28 +41,17 @@ sealed class Argument( } companion object { - fun string() = - Required(StringParser) + fun string() = argument(StringParser) + fun int() = argument(IntParser) + fun long() = argument(LongParser) + fun float() = argument(FloatParser) + fun double() = argument(DoubleParser) + fun boolean() = argument(BooleanParser) - fun int() = - Required(IntParser) - - fun long() = - Required(LongParser) - - fun float() = - Required(FloatParser) - - fun double() = - Required(DoubleParser) - - fun boolean() = - Required(BooleanParser) - - inline fun > enum() = - Required(enumParser()) - - fun regex() = - Required(RegexParser) + inline fun > enum() = argument(enumParser()) + fun regex() = argument(RegexParser) } } + +fun argument(parser: Parser) = + Argument.Required(parser) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 3936b1e..0d2d78d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -1,11 +1,13 @@ 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.exception.build.* import com.infendro.cli.exception.run.CliException import com.infendro.cli.util.Regex.COMMAND import com.infendro.cli.util.Regex.OPTION +import kotlin.reflect.KProperty class Command private constructor( val name: String?, @@ -23,6 +25,7 @@ class Command private constructor( println(e.message) } + @Dsl class Builder internal constructor( private val level: Int, private val name: String?, @@ -68,10 +71,10 @@ class Command private constructor( arguments += argument } - fun > T.register() = also(::argument) - fun arguments(vararg arguments: Argument<*>) = arguments.forEach(::argument) - fun > Array.register() = also(::arguments) + + operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::argument) + operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this fun option(option: Option<*>) { // validate @@ -85,10 +88,10 @@ class Command private constructor( options += option } - fun > T.register() = also(::option) - fun options(vararg options: Option<*>) = options.forEach(::option) - fun > Array.register() = also(::options) + + operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::option) + operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this fun execute(block: Context.() -> Unit) { if (::_execute.isInitialized) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index ab39039..5866018 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -84,28 +84,20 @@ sealed class Option( } companion object { - fun string(vararg names: String) = - Required(StringParser, names.asList()) + 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, true, *names) - fun int(vararg names: String) = - Required(IntParser, names.asList()) - - fun long(vararg names: String) = - Required(LongParser, names.asList()) - - fun float(vararg names: String) = - Required(FloatParser, names.asList()) - - fun double(vararg names: String) = - Required(DoubleParser, names.asList()) - - fun boolean(vararg names: String) = - Required(BooleanParser, true, names.asList()) - - inline fun > enum(vararg names: String) = - Required(enumParser(), names.asList()) - - fun regex(vararg names: String) = - Required(RegexParser, names.asList()) + inline fun > enum(vararg names: String) = option(enumParser(), *names) + fun regex(vararg names: String) = option(RegexParser, *names) } } + +fun option(parser: Parser, vararg names: String) = + Option.Required(parser, names.asList()) + +fun option(parser: Parser, fallback: T, vararg names: String) = + Option.Required(parser, fallback, names.asList()) 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 037d2e4..b1e53ad 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -1,10 +1,12 @@ 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 kotlin.reflect.KProperty +@Dsl class Context internal constructor( val command: Command, val commands: List, diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt new file mode 100644 index 0000000..b826d7b --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.run + +class ExecuteException( + override val message: String, +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt index 0efcf27..df2cea5 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -3,9 +3,6 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object DoubleParser : Parser { - override fun parse(text: String): Double = try { - text.toDouble() - } catch (_: Exception) { - throw ParseException(text, "Double") - } + override fun parse(text: String): Double = text.toDoubleOrNull() + ?: throw ParseException(text, "Double") } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt index b4247a4..30dfa79 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -7,11 +7,8 @@ class EnumParser>( private val name: String, private val values: List, ) : Parser { - override fun parse(text: String): T = try { - values.first { it.name == text } - } catch (_: Exception) { - throw ParseException(text, name) - } + override fun parse(text: String): T = values.firstOrNull { it.name == text } + ?: throw ParseException(text, name) } inline fun > enumParser() = EnumParser(T::class.simpleName!!, enumEntries()) diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt index 28434c3..cdef5ec 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -3,9 +3,6 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object FloatParser : Parser { - override fun parse(text: String): Float = try { - text.toFloat() - } catch (_: Exception) { - throw ParseException(text, "Float") - } + override fun parse(text: String): Float = text.toFloatOrNull() + ?: throw ParseException(text, "Float") } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt index 750920a..33be4af 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -3,9 +3,6 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object IntParser : Parser { - override fun parse(text: String): Int = try { - text.toInt() - } catch (_: Exception) { - throw ParseException(text, "Int") - } + override fun parse(text: String): Int = text.toIntOrNull() + ?: throw ParseException(text, "Int") } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt index 8072f83..a234ab7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -3,9 +3,6 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.ParseException object LongParser : Parser { - override fun parse(text: String): Long = try { - text.toLong() - } catch (_: Exception) { - throw ParseException(text, "Long") - } + override fun parse(text: String): Long = text.toLongOrNull() + ?: throw ParseException(text, "Long") } From 8e491751f59873def4ddd7e3b1f0ef7e83006c62 Mon Sep 17 00:00:00 2001 From: Infendro Date: Tue, 6 Jan 2026 11:14:14 +0100 Subject: [PATCH 08/20] remove Regex argument/option out of scope --- src/commonMain/kotlin/com/infendro/cli/command/Argument.kt | 1 - src/commonMain/kotlin/com/infendro/cli/command/Option.kt | 1 - src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt | 5 ----- 3 files changed, 7 deletions(-) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 6a1ad1b..05c0159 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -49,7 +49,6 @@ sealed class Argument( fun boolean() = argument(BooleanParser) inline fun > enum() = argument(enumParser()) - fun regex() = argument(RegexParser) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt index 5866018..b68836a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -92,7 +92,6 @@ sealed class Option( fun boolean(vararg names: String) = option(BooleanParser, true, *names) inline fun > enum(vararg names: String) = option(enumParser(), *names) - fun regex(vararg names: String) = option(RegexParser, *names) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt deleted file mode 100644 index 098c4c1..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/parser/RegexParser.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.parser - -object RegexParser : Parser { - override fun parse(text: String) = Regex(text) -} From 4e9b358ef88c84764525b67cca0c8b3162bb18d6 Mon Sep 17 00:00:00 2001 From: Infendro Date: Tue, 6 Jan 2026 22:47:21 +0100 Subject: [PATCH 09/20] implement optional variable argument/option ranges, refactor exception handling adapt required/optional argument handling implement bounded/unbounded variable argument handling --- README.md | 11 +- src/commonMain/kotlin/com/infendro/cli/Dsl.kt | 2 +- .../com/infendro/cli/command/Argument.kt | 73 ++++++----- .../com/infendro/cli/command/Command.kt | 116 ++++++++++-------- .../kotlin/com/infendro/cli/command/Option.kt | 56 +++++---- .../infendro/cli/command/context/Context.kt | 4 +- .../infendro/cli/command/context/Parser.kt | 57 +++++---- .../infendro/cli/exception/CliException.kt | 3 + .../exception/build/ArgumentOrderException.kt | 3 - .../cli/exception/build/BuildException.kt | 5 + .../cli/exception/build/DuplicateCommand.kt | 5 + .../build/DuplicateCommandException.kt | 5 - ...dOrderException.kt => DuplicateExecute.kt} | 2 +- .../build/DuplicateExecuteException.kt | 3 - ...eOptionException.kt => DuplicateOption.kt} | 4 +- ...ionNameException.kt => InvalidArgument.kt} | 4 +- .../exception/build/InvalidArgumentOrder.kt | 3 + ...mandNameException.kt => InvalidCommand.kt} | 4 +- .../exception/build/InvalidCommandOrder.kt | 3 + .../cli/exception/build/InvalidFallback.kt | 3 + .../build/InvalidFallbackException.kt | 3 - .../cli/exception/build/InvalidOption.kt | 5 + .../cli/exception/build/InvalidRange.kt | 3 + .../cli/exception/build/MissingOptionName.kt | 3 + .../build/MissingOptionNameException.kt | 3 - .../cli/exception/run/CliException.kt | 3 - .../exception/run/DuplicateOptionException.kt | 8 -- .../cli/exception/run/ExecuteException.kt | 5 - .../cli/exception/run/InvalidArgumentCount.kt | 18 +++ .../cli/exception/run/InvalidOptionCount.kt | 18 +++ .../cli/exception/run/InvalidValue.kt | 11 ++ ...dOptionException.kt => MalformedOption.kt} | 4 +- .../exception/run/MissingArgumentException.kt | 6 - .../exception/run/MissingOptionException.kt | 8 -- .../cli/exception/run/MissingOptionValue.kt | 10 ++ .../run/MissingOptionValueException.kt | 8 -- .../cli/exception/run/ParseException.kt | 9 -- .../cli/exception/run/RunException.kt | 5 + .../run/TooManyArgumentsException.kt | 6 - .../cli/exception/run/UnexpectedArgument.kt | 6 + ...nCommandException.kt => UnknownCommand.kt} | 4 +- ...ownOptionException.kt => UnknownOption.kt} | 4 +- .../com/infendro/cli/parser/BooleanParser.kt | 4 +- .../com/infendro/cli/parser/DoubleParser.kt | 4 +- .../com/infendro/cli/parser/EnumParser.kt | 9 +- .../com/infendro/cli/parser/FloatParser.kt | 4 +- .../com/infendro/cli/parser/IntParser.kt | 4 +- .../com/infendro/cli/parser/LongParser.kt | 4 +- .../kotlin/com/infendro/cli/util/Regex.kt | 1 + 49 files changed, 315 insertions(+), 231 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommandException.kt rename src/commonMain/kotlin/com/infendro/cli/exception/build/{CommandOrderException.kt => DuplicateExecute.kt} (50%) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecuteException.kt rename src/commonMain/kotlin/com/infendro/cli/exception/build/{DuplicateOptionException.kt => DuplicateOption.kt} (57%) rename src/commonMain/kotlin/com/infendro/cli/exception/build/{IllegalOptionNameException.kt => InvalidArgument.kt} (56%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt rename src/commonMain/kotlin/com/infendro/cli/exception/build/{IllegalCommandNameException.kt => InvalidCommand.kt} (55%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt rename src/commonMain/kotlin/com/infendro/cli/exception/run/{MalformedOptionException.kt => MalformedOption.kt} (73%) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt rename src/commonMain/kotlin/com/infendro/cli/exception/run/{UnknownCommandException.kt => UnknownCommand.kt} (74%) rename src/commonMain/kotlin/com/infendro/cli/exception/run/{UnknownOptionException.kt => UnknownOption.kt} (74%) diff --git a/README.md b/README.md index 3d462be..ec38adf 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,20 @@ dependencies { ## Usage ```kotlin -fun main(args: Array) = cli { +fun main(args: Array) = cli("application") { execute { // define execution of command (e.g., display help) + println("Usage: application [arguments] [options]") } // declare command "greet" command("greet") { // declare arguments - val speakersArg = Argument.string().variable().register() + val speakersArg by Argument.string("speakers").variable(min = 1) // declare options - val greetingOpt = Option.string("greeting", "greet", "g").orElse("Hello").register() - val nameOpt = Option.string("name", "n").orNull().register() + val greetingOpt by Option.string("greeting", "greet", "g").orElse("Hello") + val nameOpt by Option.string("name", "n").orNull() execute { // retrieve the arguments and options @@ -55,7 +56,7 @@ fun main(args: Array) = cli { } // declare fallback command - fallback { valueArg -> + fallback("value") { valueArg -> execute { // retrieve the value used for the fallback command val value by valueArg diff --git a/src/commonMain/kotlin/com/infendro/cli/Dsl.kt b/src/commonMain/kotlin/com/infendro/cli/Dsl.kt index 5705e9a..084718c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/Dsl.kt +++ b/src/commonMain/kotlin/com/infendro/cli/Dsl.kt @@ -1,4 +1,4 @@ package com.infendro.cli @DslMarker -internal annotation class Dsl() +internal annotation class Dsl diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt index 05c0159..c9a39fd 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt @@ -1,56 +1,71 @@ package com.infendro.cli.command +import com.infendro.cli.exception.build.InvalidRange import com.infendro.cli.parser.* sealed class Argument( val parser: Parser, + val name: String, + val min: Int, + val max: Int?, ) { - abstract val required: Boolean + init { + when { + min !in 0..count -> throw InvalidRange() + } + } + + val required: Boolean + get() = min > 0 + val optional: Boolean + get() = !required + + val bounded: Boolean + get() = max != null + val unbounded: Boolean + get() = !bounded + + val count: Int + get() = max ?: Int.MAX_VALUE class Required( parser: Parser, - ) : Argument(parser) { - override val required: Boolean - get() = true - - fun orElse(other: T) = OrElse(parser, other) - fun orNull() = OrNull(parser) - fun variable() = Variable(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) } class OrElse( parser: Parser, + name: String, val other: T, - ) : Argument(parser) { - override val required: Boolean - get() = false - } + ) : Argument(parser, name, min = 0, max = 1) class OrNull( parser: Parser, - ) : Argument(parser) { - override val required: Boolean - get() = false - } + name: String, + ) : Argument(parser, name, min = 0, max = 1) class Variable( parser: Parser, - ) : Argument(parser) { - override val required: Boolean - get() = false - } + name: String, + min: Int, + max: Int?, + ) : Argument(parser, name, min, max) companion object { - fun string() = argument(StringParser) - fun int() = argument(IntParser) - fun long() = argument(LongParser) - fun float() = argument(FloatParser) - fun double() = argument(DoubleParser) - fun boolean() = argument(BooleanParser) + 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) - inline fun > enum() = argument(enumParser()) + inline fun > enum(name: String) = argument(enumParser(), name) } } -fun argument(parser: Parser) = - Argument.Required(parser) +fun argument(parser: Parser, name: String) = + Argument.Required(parser, name) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 0d2d78d..a26886d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -4,31 +4,31 @@ import com.infendro.cli.Dsl import com.infendro.cli.command.context.Context import com.infendro.cli.command.context.Parser import com.infendro.cli.exception.build.* -import com.infendro.cli.exception.run.CliException +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 name: String?, + val name: String, + val fallback: Boolean, val commands: List, val arguments: List>, val options: List>, internal val execute: Context.() -> Unit, ) { - val fallback: Boolean - get() = name == null - fun run(args: Array) = try { Parser.parse(this, args).execute() - } catch (e: CliException) { + } catch (e: RunException) { println(e.message) } @Dsl class Builder internal constructor( private val level: Int, - private val name: String?, + private val name: String, + private val fallback: Boolean, ) { private val commands = mutableListOf() private val arguments = mutableListOf>() @@ -39,35 +39,17 @@ class Command private constructor( get() = Key(level) fun command(name: String, block: Builder.() -> Unit) { - // validate - val last = commands.lastOrNull() - when { - last != null && last.fallback -> throw CommandOrderException() - !name.matches(COMMAND) -> throw IllegalCommandNameException(name) - commands.any { it.name == name } -> throw DuplicateCommandException(name) - } - - commands += Builder(level + 1, name).also(block).build() + validateCommand(name) + commands += Builder(level + 1, name, fallback = false).apply(block).build() } - fun fallback(block: Builder.(Key) -> Unit) { - // validate - val last = commands.lastOrNull() - when { - last != null && last.fallback -> throw CommandOrderException() - } - - commands += Builder(level + 1, null).also { it.block(it.key) }.build() + fun fallback(name: String, block: Builder.(Key) -> Unit) { + validateCommand(name) + commands += Builder(level + 1, name, fallback = true).apply { block(key) }.build() } fun argument(argument: Argument<*>) { - // validate - val last = arguments.lastOrNull() - when { - last is Argument.Variable -> throw ArgumentOrderException() - argument.required && last != null && !last.required -> throw ArgumentOrderException() - } - + validateArgument(argument) arguments += argument } @@ -77,14 +59,7 @@ class Command private constructor( operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this fun option(option: Option<*>) { - // validate - for (name in option.names) { - when { - !name.matches(OPTION) -> throw IllegalOptionNameException(name) - options.any { name in it.names } -> throw DuplicateOptionException(name) - } - } - + validateOption(option) options += option } @@ -94,23 +69,62 @@ class Command private constructor( operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this fun execute(block: Context.() -> Unit) { - if (::_execute.isInitialized) - throw DuplicateExecuteException() - + validateExecute() _execute = block } fun build(): Command { - // validate - val last = commands.lastOrNull() - when { - last != null && last.fallback && arguments.isNotEmpty() -> throw InvalidFallbackException() - } - if (!::_execute.isInitialized) _execute = {} - return Command(name, commands, arguments, options, _execute) + validate() + return Command(name, fallback, commands, arguments, options, _execute) + } + + private fun validateCommand(name: String) { + if (commands.isNotEmpty()) { + val last = commands.last() + when { + last.fallback -> throw InvalidCommandOrder() + } + } + when { + !name.matches(COMMAND) -> throw InvalidCommand(name) + commands.any { it.name == name } -> throw DuplicateCommand(name) + } + } + + private fun validateArgument(argument: Argument<*>) { + if (arguments.isNotEmpty()) { + val last = arguments.last() + when { + last.unbounded -> throw InvalidArgumentOrder() + argument.required && last.optional -> throw InvalidArgumentOrder() + } + } + when { + !argument.name.matches(ARGUMENT) -> throw InvalidArgument(argument.name) + } + } + + private fun validateOption(option: Option<*>) { + for (name in option.names) { + when { + !name.matches(OPTION) -> throw InvalidOption(name) + options.any { name in it.names } -> throw DuplicateOption(name) + } + } + } + + private fun validateExecute() { + if (::_execute.isInitialized) + throw DuplicateExecute() + } + + private fun validate() { + if (commands.any { it.fallback } && arguments.isNotEmpty()) { + throw InvalidFallback() + } } } @@ -119,5 +133,5 @@ class Command private constructor( ) } -fun cli(block: Command.Builder.() -> Unit) = - Command.Builder(-1, null).also(block).build() +fun cli(name: String, block: Command.Builder.() -> Unit) = + Command.Builder(-1, name, fallback = false).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 b68836a..5da02f1 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Option.kt @@ -1,42 +1,55 @@ package com.infendro.cli.command -import com.infendro.cli.exception.build.MissingOptionNameException +import com.infendro.cli.exception.build.InvalidRange +import com.infendro.cli.exception.build.MissingOptionName import com.infendro.cli.parser.* sealed class Option( val parser: Parser, val fallback: T?, val names: List, + val min: Int, + val max: Int?, ) { + val name: String + get() = names.maxBy { it.length } + val flag: Boolean get() = fallback != null - val name: String - get() = names[0] + val required: Boolean + get() = min > 0 + val optional: Boolean + get() = !required - abstract val required: Boolean + val bounded: Boolean + get() = max != null + val unbounded: Boolean + get() = !bounded + + val count: Int + get() = max ?: Int.MAX_VALUE init { - if (names.isEmpty()) - throw MissingOptionNameException() + when { + names.isEmpty() -> throw MissingOptionName() + min !in 0..count -> throw InvalidRange() + } } class Required( parser: Parser, fallback: T?, names: List, - ) : Option(parser, fallback, names) { + ) : Option(parser, fallback, names, min = 1, max = 1) { constructor( parser: Parser, names: List, ) : this(parser, null, names) - override val required: Boolean - get() = true - fun orElse(other: T) = OrElse(parser, fallback, names, other) fun orNull() = OrNull(parser, fallback, names) - fun variable() = Variable(parser, fallback, names) + fun variable(min: Int = 0, max: Int? = null) = Variable(parser, fallback, names, min, max) } class OrElse( @@ -44,43 +57,38 @@ sealed class Option( fallback: T?, names: List, val other: T, - ) : Option(parser, fallback, names) { + ) : Option(parser, fallback, names, min = 0, max = 1) { constructor( parser: Parser, names: List, other: T, ) : this(parser, null, names, other) - - override val required: Boolean - get() = false } class OrNull( parser: Parser, fallback: T?, names: List, - ) : Option(parser, fallback, names) { + ) : Option(parser, fallback, names, min = 0, max = 1) { constructor( parser: Parser, names: List, ) : this(parser, null, names) - - override val required: Boolean - get() = false } class Variable( parser: Parser, fallback: T?, names: List, - ) : Option(parser, fallback, names) { + min: Int, + max: Int?, + ) : Option(parser, fallback, names, min, max) { constructor( parser: Parser, names: List, - ) : this(parser, null, names) - - override val required: Boolean - get() = false + min: Int, + max: Int?, + ) : this(parser, null, names, min, max) } companion object { 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 b1e53ad..42aa429 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -31,7 +31,7 @@ class Context internal constructor( operator fun Command.Key.getValue(thisRef: Any?, property: KProperty<*>): String = commands[index].name private val Argument.index: Int - get() = command.arguments.indexOf(this) + get() = command.arguments.takeWhile { it != this }.sumOf { it.count } @Suppress("UNCHECKED_CAST") private val Argument.value: T? @@ -39,7 +39,7 @@ class Context internal constructor( @Suppress("UNCHECKED_CAST") private val Argument.Variable.values: List - get() = arguments.drop(index).map { it.value as T } + get() = arguments.drop(index).take(count).map { it.value as T } operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other 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 d6a8e7b..42e2cdf 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt @@ -1,8 +1,6 @@ package com.infendro.cli.command.context -import com.infendro.cli.command.Argument import com.infendro.cli.command.Command -import com.infendro.cli.command.Option import com.infendro.cli.command.context.Context.* import com.infendro.cli.exception.run.* @@ -25,7 +23,7 @@ internal object Parser { val c = command.commands.firstOrNull { it.name == current || it.fallback } if (c == null) { when { - command.arguments.isEmpty() -> throw UnknownCommandException(current) + command.arguments.isEmpty() -> throw UnknownCommand(current) else -> break } } @@ -39,6 +37,7 @@ internal object Parser { // 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) @@ -47,13 +46,16 @@ internal object Parser { // argument endOfOptions || dashes == 0 -> { val argument = command.arguments.getOrNull(argumentIndex) - ?: throw TooManyArgumentsException() + ?: throw UnexpectedArgument() - if (argument !is Argument.Variable) - argumentIndex++ - - val value = argument.parser.parse(current) as Any + val value = argument.parser.parse(current) arg += Arg(value) + argumentCount++ + + if (argumentCount == argument.count) { + argumentCount = 0 + argumentIndex++ + } } // option @@ -88,12 +90,12 @@ internal object Parser { for (name in names) { val option = command.options.firstOrNull { name in it.names } - ?: throw UnknownOptionException(name) + ?: throw UnknownOption(name) val value = when { text != null -> option.parser.parse(text) option.flag -> option.fallback!! - else -> throw MissingOptionValueException(name) + else -> throw MissingOptionValue(option) } opt += Opt(name, value) } @@ -104,9 +106,9 @@ internal object Parser { when { args[i].contains('=') -> { val (option) = args[i].split('=', limit = 2) - throw MalformedOptionException(option) + throw MalformedOption(option) } - else -> throw MalformedOptionException(args[i]) + else -> throw MalformedOption(args[i]) } } } @@ -114,17 +116,28 @@ internal object Parser { i++ } - if (arg.size < command.arguments.count { it is Argument.Required }) - throw MissingArgumentException() - - for (option in command.options) { - val count = opt.count { it.name in option.names } - when { - option is Option.Required && count == 0 -> throw MissingOptionException(option.name) - option !is Option.Variable && count > 1 -> throw DuplicateOptionException(option.name) - } - } + validateArguments(command, arg) + validateOptions(command, opt) return Context(command, cmd, opt, arg) } + + private fun validateArguments(command: Command, arg: List) { + 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) + + consumed += needed + } + } + + private fun validateOptions(command: Command, opt: List) { + 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) + } + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt new file mode 100644 index 0000000..913de16 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception + +abstract class CliException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt deleted file mode 100644 index f04bcf4..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/ArgumentOrderException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class ArgumentOrderException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt new file mode 100644 index 0000000..78c7397 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.build + +import com.infendro.cli.exception.CliException + +abstract class BuildException : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt new file mode 100644 index 0000000..5f02c1d --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.build + +class DuplicateCommand( + val command: String, +) : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommandException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommandException.kt deleted file mode 100644 index c83df29..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommandException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateCommandException( - val command: String?, -) : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt similarity index 50% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt index 6ed8242..0a106cb 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/CommandOrderException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class CommandOrderException : Exception() +class DuplicateExecute : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecuteException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecuteException.kt deleted file mode 100644 index 91134a1..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecuteException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateExecuteException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt similarity index 57% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt index 96ca86d..fe0bc83 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOptionException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt @@ -1,5 +1,5 @@ package com.infendro.cli.exception.build -class DuplicateOptionException( +class DuplicateOption( val name: String, -) : Exception() +) : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt similarity index 56% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt index 88dcc73..cc59440 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalOptionNameException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt @@ -1,5 +1,5 @@ package com.infendro.cli.exception.build -class IllegalOptionNameException( +class InvalidArgument( val name: String, -) : Exception() +) : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt new file mode 100644 index 0000000..c7628e6 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class InvalidArgumentOrder : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt similarity index 55% rename from src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt index f857b99..f869336 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt @@ -1,5 +1,5 @@ package com.infendro.cli.exception.build -class IllegalCommandNameException( +class InvalidCommand( val name: String, -) : Exception() +) : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt new file mode 100644 index 0000000..582268a --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class InvalidCommandOrder : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt new file mode 100644 index 0000000..f510674 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class InvalidFallback : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt deleted file mode 100644 index 07e66ee..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallbackException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidFallbackException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt new file mode 100644 index 0000000..87ee939 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.build + +class InvalidOption( + val name: String, +) : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt new file mode 100644 index 0000000..989a180 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class InvalidRange : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt new file mode 100644 index 0000000..7f19fd2 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.exception.build + +class MissingOptionName : BuildException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt deleted file mode 100644 index bd969f8..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionNameException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class MissingOptionNameException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt deleted file mode 100644 index c84ca0f..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/CliException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.run - -abstract class CliException : Exception() \ No newline at end of file diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt deleted file mode 100644 index d6b84e7..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/DuplicateOptionException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.infendro.cli.exception.run - -class DuplicateOptionException( - val option: String, -) : CliException() { - override val message: String - get() = """duplicate option "$option"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt deleted file mode 100644 index b826d7b..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/ExecuteException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.run - -class ExecuteException( - override val message: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt new file mode 100644 index 0000000..86848b4 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt @@ -0,0 +1,18 @@ +package com.infendro.cli.exception.run + +import com.infendro.cli.command.Argument + +class InvalidArgumentCount( + val argument: Argument<*>, + val count: Int, +) : RunException() { + override val message: String + get() { + val expected = when { + argument.min == argument.max -> "${argument.min}" + 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""" + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt new file mode 100644 index 0000000..0b6c7fd --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt @@ -0,0 +1,18 @@ +package com.infendro.cli.exception.run + +import com.infendro.cli.command.Option + +class InvalidOptionCount( + val option: Option<*>, + val count: Int, +) : RunException() { + override val message: String + get() { + val expected = when { + option.min == option.max -> "${option.min}" + 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""" + } +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt new file mode 100644 index 0000000..bdd69e3 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.exception.run + +import kotlin.reflect.KClass + +class InvalidValue( + val value: String, + val type: KClass<*>, +) : RunException() { + override val message: String + get() = """"$value" cannot be converted to ${type.simpleName}""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt similarity index 73% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt index 0e97e3d..9ea3d06 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOptionException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt @@ -1,8 +1,8 @@ package com.infendro.cli.exception.run -class MalformedOptionException( +class MalformedOption( val option: String, -) : CliException() { +) : RunException() { override val message: String get() = """malformed option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt deleted file mode 100644 index fba26c7..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.infendro.cli.exception.run - -class MissingArgumentException : CliException() { - override val message: String - get() = """missing argument""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt deleted file mode 100644 index 317956f..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.infendro.cli.exception.run - -class MissingOptionException( - val option: String, -) : CliException() { - override val message: String - get() = """missing option "$option"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt new file mode 100644 index 0000000..20c2a27 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.exception.run + +import com.infendro.cli.command.Option + +class MissingOptionValue( + val option: Option<*>, +) : RunException() { + override val message: String + get() = """no value provided for option "${option.name}"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt deleted file mode 100644 index 24b2cb7..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValueException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.infendro.cli.exception.run - -class MissingOptionValueException( - val option: String, -) : CliException() { - override val message: String - get() = """no value provided for option "$option"""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt deleted file mode 100644 index 2206a0b..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.infendro.cli.exception.run - -class ParseException( - val text: String?, - val type: String, -) : CliException() { - override val message: String - get() = """"$text" cannot be converted to $type""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt new file mode 100644 index 0000000..5c8a626 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.run + +import com.infendro.cli.exception.CliException + +abstract class RunException : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt deleted file mode 100644 index fe293ff..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/TooManyArgumentsException.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.infendro.cli.exception.run - -class TooManyArgumentsException : CliException() { - override val message: String - get() = """too many arguments""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt new file mode 100644 index 0000000..bf63c2c --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.exception.run + +class UnexpectedArgument : RunException() { + override val message: String + get() = """unexpected argument""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt similarity index 74% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt index 5568c26..4fb1117 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt @@ -1,8 +1,8 @@ package com.infendro.cli.exception.run -class UnknownCommandException( +class UnknownCommand( val command: String, -) : CliException() { +) : RunException() { override val message: String get() = """unknown command "$command"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt similarity index 74% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt index 4fe2e48..38c9a41 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOptionException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt @@ -1,8 +1,8 @@ package com.infendro.cli.exception.run -class UnknownOptionException( +class UnknownOption( val option: String, -) : CliException() { +) : RunException() { override val message: String get() = """unknown option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt index ce894ab..5030413 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -1,11 +1,11 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue object BooleanParser : Parser { override fun parse(text: String): Boolean = when (text) { "true", "t" -> true "false", "f" -> false - else -> throw ParseException(text, "Boolean") + else -> throw InvalidValue(text, Boolean::class) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt index df2cea5..20f8774 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -1,8 +1,8 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue object DoubleParser : Parser { override fun parse(text: String): Double = text.toDoubleOrNull() - ?: throw ParseException(text, "Double") + ?: throw InvalidValue(text, Double::class) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt index 30dfa79..0dd7806 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -1,14 +1,15 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue import kotlin.enums.enumEntries +import kotlin.reflect.KClass class EnumParser>( - private val name: String, + private val type: KClass, private val values: List, ) : Parser { override fun parse(text: String): T = values.firstOrNull { it.name == text } - ?: throw ParseException(text, name) + ?: throw InvalidValue(text, type) } -inline fun > enumParser() = EnumParser(T::class.simpleName!!, enumEntries()) +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 cdef5ec..65a774a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -1,8 +1,8 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue object FloatParser : Parser { override fun parse(text: String): Float = text.toFloatOrNull() - ?: throw ParseException(text, "Float") + ?: throw InvalidValue(text, Float::class) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt index 33be4af..4df6a2e 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -1,8 +1,8 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue object IntParser : Parser { override fun parse(text: String): Int = text.toIntOrNull() - ?: throw ParseException(text, "Int") + ?: throw InvalidValue(text, Int::class) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt index a234ab7..64152bc 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -1,8 +1,8 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.ParseException +import com.infendro.cli.exception.run.InvalidValue object LongParser : Parser { override fun parse(text: String): Long = text.toLongOrNull() - ?: throw ParseException(text, "Long") + ?: throw InvalidValue(text, Long::class) } diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt index 1eeb52c..cf613b4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt +++ b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt @@ -2,5 +2,6 @@ package com.infendro.cli.util internal object Regex { val COMMAND = Regex("""[a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)*""") + val ARGUMENT = Regex("""[a-zA-Z][a-zA-Z0-9]*(?:[\-.][a-zA-Z0-9]+)*""") val OPTION = Regex("""[a-zA-Z][a-zA-Z0-9]*(?:[\-.][a-zA-Z0-9]+)*""") } From ecd255479abd9e2da89442c2170f1ea9b92fb9fd Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 7 Jan 2026 18:53:35 +0100 Subject: [PATCH 10/20] 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 } From 921229aa5b35345f55fdd46ca7a4792d76f5c7c6 Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 7 Jan 2026 21:44:25 +0100 Subject: [PATCH 11/20] implement type-safe fallback commands --- README.md | 8 +- .../com/infendro/cli/command/Command.kt | 175 +++++++++++++----- .../cli/command/{ => argument}/Argument.kt | 18 +- .../cli/command/argument/ArgumentFactory.kt | 19 ++ .../infendro/cli/command/context/Context.kt | 75 ++++---- .../context/{Parser.kt => ContextParser.kt} | 91 +++++---- .../cli/command/help/DefaultHelpRenderer.kt | 6 +- .../cli/command/{ => option}/Option.kt | 21 +-- .../cli/command/option/OptionFactory.kt | 22 +++ .../cli/exception/build/BuildException.kt | 5 - .../cli/exception/{ => build}/CliException.kt | 2 +- .../cli/exception/build/DuplicateCommand.kt | 2 +- .../cli/exception/build/DuplicateExecute.kt | 2 +- .../cli/exception/build/DuplicateOption.kt | 2 +- .../cli/exception/build/InvalidArgument.kt | 2 +- .../exception/build/InvalidArgumentOrder.kt | 2 +- .../cli/exception/build/InvalidCommand.kt | 2 +- .../exception/build/InvalidCommandOrder.kt | 2 +- .../cli/exception/build/InvalidFallback.kt | 2 +- .../cli/exception/build/InvalidOption.kt | 2 +- .../cli/exception/build/InvalidRange.kt | 2 +- .../cli/exception/build/MissingOptionName.kt | 2 +- .../cli/exception/run/InvalidArgumentCount.kt | 4 +- .../cli/exception/run/InvalidOptionCount.kt | 4 +- .../cli/exception/run/InvalidValue.kt | 2 +- .../cli/exception/run/MalformedOption.kt | 2 +- .../cli/exception/run/MissingOptionValue.kt | 4 +- .../cli/exception/run/RunException.kt | 5 - .../cli/exception/run/RuntimeError.kt | 5 + .../cli/exception/run/UnexpectedArgument.kt | 2 +- .../cli/exception/run/UnknownCommand.kt | 2 +- .../cli/exception/run/UnknownOption.kt | 2 +- .../com/infendro/cli/parser/BooleanParser.kt | 18 +- .../com/infendro/cli/parser/DoubleParser.kt | 13 +- .../com/infendro/cli/parser/EnumParser.kt | 10 +- .../com/infendro/cli/parser/FloatParser.kt | 13 +- .../com/infendro/cli/parser/IntParser.kt | 13 +- .../com/infendro/cli/parser/LongParser.kt | 13 +- .../kotlin/com/infendro/cli/parser/Parser.kt | 20 +- .../com/infendro/cli/parser/StringParser.kt | 10 +- 40 files changed, 345 insertions(+), 261 deletions(-) rename src/commonMain/kotlin/com/infendro/cli/command/{ => argument}/Argument.kt (66%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt rename src/commonMain/kotlin/com/infendro/cli/command/context/{Parser.kt => ContextParser.kt} (65%) rename src/commonMain/kotlin/com/infendro/cli/command/{ => option}/Option.kt (73%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt rename src/commonMain/kotlin/com/infendro/cli/exception/{ => build}/CliException.kt (51%) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt diff --git a/README.md b/README.md index 5b5478f..f999f46 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ fun main(args: Array) = cli("application") { // declare command "greet" command("greet") { // declare arguments - val speakersArg by Argument.string("speakers").variable(min = 1) + val speakersArg by argument.string("speakers").variable(min = 1) // declare options - val greetingOpt by Option.string("greeting", "greet", "g").orElse("Hello") - val nameOpt by Option.string("name", "n").orNull() + val greetingOpt by option.string("greeting", "greet", "g").orElse("Hello") + val nameOpt by option.string("name", "n").orNull() execute { // retrieve the arguments and options @@ -56,7 +56,7 @@ fun main(args: Array) = cli("application") { } // declare fallback command - fallback("value") { valueArg -> + fallback.string("value") { 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 cb9d0f8..b0ae597 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -1,20 +1,22 @@ package com.infendro.cli.command import com.infendro.cli.Dsl +import com.infendro.cli.command.argument.Argument import com.infendro.cli.command.context.Context -import com.infendro.cli.command.context.Parser +import com.infendro.cli.command.context.ContextParser import com.infendro.cli.command.help.DefaultHelpRenderer import com.infendro.cli.command.help.HelpRenderer +import com.infendro.cli.command.option.Option import com.infendro.cli.exception.build.* +import com.infendro.cli.parser.* +import com.infendro.cli.util.Regex 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( +sealed class Command private constructor( val parent: Command?, val name: String, - val fallback: Boolean, val commands: List, val arguments: List>, val options: List>, @@ -31,60 +33,130 @@ class Command private constructor( 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") } + when (val result = ContextParser(this, args).parse()) { + is ContextParser.Result.Success -> result.context.execute() + is ContextParser.Result.Help -> result.command.help?.let { print(it) } + is ContextParser.Result.Failure -> { + println("error: ${result.error.message}") + result.command.help?.let { + println() + print(it) + } } } } + class Named( + parent: Command?, + name: String, + commands: List, + arguments: List>, + options: List>, + execute: Context.() -> Unit, + renderer: HelpRenderer, + ) : Command(parent, name, commands, arguments, options, execute, renderer) { + class Builder( + name: String, + ) : Command.Builder(name) { + override fun create() = + Named(parent, name, commands, arguments, options, execute, renderer) + } + } + + class Fallback( + parent: Command?, + val key: Key, + val parser: Parser, + name: String, + commands: List, + arguments: List>, + options: List>, + execute: Context.() -> Unit, + renderer: HelpRenderer, + ) : Command(parent, name, commands, arguments, options, execute, renderer) { + class Builder( + val parser: Parser, + name: String, + ) : Command.Builder>(name) { + internal val key = Key() + + override fun create() = + Fallback(parent, key, parser, name, commands, arguments, options, execute, renderer) + } + + class Key + } + @Dsl - class Builder internal constructor( - private val level: Int, - private val name: String, - private val fallback: Boolean, - var renderer: HelpRenderer, + sealed class Builder( + protected val name: String, ) { - private var parent: Command? = null - private val builders = mutableListOf() - private val arguments = mutableListOf>() - private val options = mutableListOf>() - private lateinit var _execute: Context.() -> Unit + protected var parent: Command? = null + private val builders = mutableListOf>() + protected val commands = mutableListOf() + protected val arguments = mutableListOf>() + protected val options = mutableListOf>() + private var _execute: (Context.() -> Unit)? = null + private var _renderer: HelpRenderer? = null - private val key: Key - get() = Key(level) + protected val execute: Context.() -> Unit + get() = _execute!! + protected val renderer: HelpRenderer + get() = _renderer!! - fun command(name: String, block: Builder.() -> Unit) { + fun command(name: String, block: Builder<*>.() -> Unit) { validateCommand(name) - builders += Builder(level + 1, name, fallback = false, renderer).apply(block) + builders += Named.Builder(name).apply(block) } - fun fallback(name: String, block: Builder.(Key) -> Unit) { - validateCommand(name) - builders += Builder(level + 1, name, fallback = true, renderer).apply { block(key) } + inner class FallbackFactory { + fun string(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(StringParser, name, block) + + fun int(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(IntParser, name, block) + + fun long(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(LongParser, name, block) + + fun float(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(FloatParser, name, block) + + fun double(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(DoubleParser, name, block) + + fun boolean(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(BooleanParser, name, block) + + inline fun > enum(name: String, noinline block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(enumParser(), name, block) } - fun argument(argument: Argument<*>) { + val fallback = FallbackFactory() + + fun fallback(parser: Parser, name: String, block: Fallback.Builder.(Fallback.Key) -> Unit) { + validateCommand(name) + builders += Fallback.Builder(parser, name).apply { block(key) } + } + + fun register(argument: Argument<*>) { validateArgument(argument) arguments += argument } - fun arguments(vararg arguments: Argument<*>) = arguments.forEach(::argument) + fun register(vararg arguments: Argument<*>) = arguments.forEach(::register) - operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::argument) + operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register) operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this - fun option(option: Option<*>) { + fun register(option: Option<*>) { validateOption(option) options += option } - fun options(vararg options: Option<*>) = options.forEach(::option) + fun register(vararg options: Option<*>) = options.forEach(::register) - operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::option) + operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register) operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this fun execute(block: Context.() -> Unit) { @@ -92,28 +164,35 @@ class Command private constructor( _execute = block } - fun build(): Command { - if (!::_execute.isInitialized) - _execute = {} + fun renderer(renderer: HelpRenderer) { + validateRenderer() + _renderer = renderer + } + + internal fun build(): Command { + if (_execute == null) _execute = {} + if (_renderer == null) _renderer = DefaultHelpRenderer validate() + val command = create() - 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 } + internal abstract fun create(): COMMAND + private fun validateCommand(name: String) { if (builders.isNotEmpty()) { val last = builders.last() - if (last.fallback) throw InvalidCommandOrder() + if (last is Fallback.Builder<*>) throw InvalidCommandOrder() } when { - !name.matches(COMMAND) -> throw InvalidCommand(name) + !name.matches(Regex.COMMAND) -> throw InvalidCommand(name) builders.any { it.name == name } -> throw DuplicateCommand(name) } } @@ -139,18 +218,18 @@ class Command private constructor( } private fun validateExecute() { - if (::_execute.isInitialized) throw DuplicateExecute() + if (_execute != null) throw DuplicateExecute() + } + + private fun validateRenderer() { + if (_renderer != null) throw Exception() //TODO } private fun validate() { - if (builders.any { it.fallback } && arguments.isNotEmpty()) throw InvalidFallback() + if (builders.any { it is Fallback.Builder<*> } && arguments.isNotEmpty()) throw InvalidFallback() } } - - class Key internal constructor( - internal val index: Int, - ) } -fun cli(name: String, block: Command.Builder.() -> Unit) = - Command.Builder(-1, name, fallback = false, DefaultHelpRenderer).apply(block).build() +fun cli(name: String, block: Command.Builder<*>.() -> Unit) = + Command.Named.Builder(name).apply(block).build() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt similarity index 66% rename from src/commonMain/kotlin/com/infendro/cli/command/Argument.kt rename to src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt index ea7d1fa..489d35c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt @@ -1,7 +1,7 @@ -package com.infendro.cli.command +package com.infendro.cli.command.argument import com.infendro.cli.exception.build.InvalidRange -import com.infendro.cli.parser.* +import com.infendro.cli.parser.Parser sealed class Argument( val parser: Parser, @@ -52,18 +52,4 @@ sealed class Argument( min: Int, max: Int?, ) : Argument(parser, name, min, max) - - companion object { - 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) - - inline fun > enum(name: String) = argument(enumParser(), name) - } } - -fun argument(parser: Parser, name: String) = - Argument.Required(parser, name) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt new file mode 100644 index 0000000..fc73224 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt @@ -0,0 +1,19 @@ +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) + + inline fun > enum(name: String) = argument(enumParser(), name) +} + +val argument = ArgumentFactory + +fun argument(parser: Parser, name: String) = + Argument.Required(parser, name) 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 bfc024a..5084cb0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -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, - val arguments: List, - val options: List, + private val commands: Map, Any>, + private val arguments: Map, List>, + private val options: Map, List>, ) { - 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 Command.Fallback.Key.value: T + get() = commands[this] as T - private val Argument.index: Int - get() = command.arguments.takeWhile { it != this }.sumOf { it.count } + operator fun Command.Fallback.Key.getValue(thisRef: Any?, property: KProperty<*>): T = + value @Suppress("UNCHECKED_CAST") - private val Argument.value: T? - get() = arguments.getOrNull(index)?.value as? T + private val Argument.values: List + get() = arguments[this] as List + + operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = + values.first() + + operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = + values.firstOrNull() ?: other + + operator fun Argument.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = + values.firstOrNull() + + operator fun Argument.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = + values @Suppress("UNCHECKED_CAST") - private val Argument.Variable.values: List - get() = arguments.drop(index).take(count).map { it.value as T } + private val Option.values: List + get() = options[this] as List - operator fun Argument.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! - operator fun Argument.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other - operator fun Argument.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value - operator fun Argument.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values + operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = + values.first() - @Suppress("UNCHECKED_CAST") - private val Option.value: T? - get() = options.firstOrNull { it.name in names }?.value as? T + operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = + values.firstOrNull() ?: other - @Suppress("UNCHECKED_CAST") - private val Option.Variable.values: List - get() = options.filter { it.name in names }.map { it.value as T } + operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = + values.firstOrNull() - operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = value!! - operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = value ?: other - operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = value - operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = values + operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = + values } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt similarity index 65% rename from src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt rename to src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index 19cc5e9..07b6795 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -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, ) { - 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() - val arg = mutableListOf() - val opt = mutableListOf() - // command + var command = root + val commands = mutableMapOf, 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() } + val options = command.options.associateWith { mutableListOf() } + 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) } } 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 5c7379e..36bed08 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -1,8 +1,8 @@ package com.infendro.cli.command.help -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 object DefaultHelpRenderer : HelpRenderer { override fun render(command: Command) = buildString { @@ -26,7 +26,7 @@ object DefaultHelpRenderer : HelpRenderer { for (command in command.path) { append(" ") when { - command.fallback -> append("<${command.name}>") + command is Command.Fallback<*> -> append("<${command.name}>") else -> append(command.name) } } diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt similarity index 73% rename from src/commonMain/kotlin/com/infendro/cli/command/Option.kt rename to src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt index 5bc9d1c..ac3711a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt @@ -1,8 +1,8 @@ -package com.infendro.cli.command +package com.infendro.cli.command.option import com.infendro.cli.exception.build.InvalidRange import com.infendro.cli.exception.build.MissingOptionName -import com.infendro.cli.parser.* +import com.infendro.cli.parser.Parser sealed class Option( val parser: Parser, @@ -90,21 +90,4 @@ sealed class Option( max: Int?, ) : this(parser, null, names, min, max) } - - companion object { - 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, true, *names) - - inline fun > enum(vararg names: String) = option(enumParser(), *names) - } } - -fun option(parser: Parser, vararg names: String) = - Option.Required(parser, names.asList()) - -fun option(parser: Parser, fallback: T, vararg names: String) = - Option.Required(parser, fallback, names.asList()) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt new file mode 100644 index 0000000..82ceaab --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt @@ -0,0 +1,22 @@ +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, true, *names) + + inline fun > enum(vararg names: String) = option(enumParser(), *names) +} + +val option = OptionFactory + +fun option(parser: Parser, vararg names: String) = + Option.Required(parser, names.asList()) + +fun option(parser: Parser, fallback: T, vararg names: String) = + Option.Required(parser, fallback, names.asList()) diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt deleted file mode 100644 index 78c7397..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/BuildException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -import com.infendro.cli.exception.CliException - -abstract class BuildException : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt similarity index 51% rename from src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt rename to src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt index 913de16..1f666d7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/CliException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt @@ -1,3 +1,3 @@ -package com.infendro.cli.exception +package com.infendro.cli.exception.build abstract class CliException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt index 5f02c1d..fbddcd7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt @@ -2,4 +2,4 @@ package com.infendro.cli.exception.build class DuplicateCommand( val command: String, -) : BuildException() +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt index 0a106cb..818a437 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class DuplicateExecute : BuildException() +class DuplicateExecute : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt index fe0bc83..687e9ea 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt @@ -2,4 +2,4 @@ package com.infendro.cli.exception.build class DuplicateOption( val name: String, -) : BuildException() +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt index cc59440..ad901b4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt @@ -2,4 +2,4 @@ package com.infendro.cli.exception.build class InvalidArgument( val name: String, -) : BuildException() +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt index c7628e6..6e2a498 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class InvalidArgumentOrder : BuildException() +class InvalidArgumentOrder : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt index f869336..f49aacd 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt @@ -2,4 +2,4 @@ package com.infendro.cli.exception.build class InvalidCommand( val name: String, -) : BuildException() +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt index 582268a..4c35cf4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class InvalidCommandOrder : BuildException() +class InvalidCommandOrder : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt index f510674..cc50110 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class InvalidFallback : BuildException() +class InvalidFallback : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt index 87ee939..f336e4d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt @@ -2,4 +2,4 @@ package com.infendro.cli.exception.build class InvalidOption( val name: String, -) : BuildException() +) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt index 989a180..23eec23 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class InvalidRange : BuildException() +class InvalidRange : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt index 7f19fd2..8725a87 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt @@ -1,3 +1,3 @@ package com.infendro.cli.exception.build -class MissingOptionName : BuildException() +class MissingOptionName : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt index 86848b4..661b4c6 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt @@ -1,11 +1,11 @@ package com.infendro.cli.exception.run -import com.infendro.cli.command.Argument +import com.infendro.cli.command.argument.Argument class InvalidArgumentCount( val argument: Argument<*>, val count: Int, -) : RunException() { +) : RuntimeError() { override val message: String get() { val expected = when { diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt index 0b6c7fd..8be30ba 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt @@ -1,11 +1,11 @@ package com.infendro.cli.exception.run -import com.infendro.cli.command.Option +import com.infendro.cli.command.option.Option class InvalidOptionCount( val option: Option<*>, val count: Int, -) : RunException() { +) : RuntimeError() { override val message: String get() { val expected = when { 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 dd7fcf5..40285a0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt @@ -5,7 +5,7 @@ import kotlin.reflect.KClass class InvalidValue( val value: String, val type: KClass<*>, -) : RunException() { +) : RuntimeError() { override val message: String get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt index 9ea3d06..b107348 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt @@ -2,7 +2,7 @@ package com.infendro.cli.exception.run class MalformedOption( val option: String, -) : RunException() { +) : RuntimeError() { override val message: String get() = """malformed option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt index 20c2a27..e7f6bb9 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt @@ -1,10 +1,10 @@ package com.infendro.cli.exception.run -import com.infendro.cli.command.Option +import com.infendro.cli.command.option.Option class MissingOptionValue( val option: Option<*>, -) : RunException() { +) : RuntimeError() { override val message: String get() = """no value provided for option "${option.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt deleted file mode 100644 index 5c8a626..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/RunException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -abstract class RunException : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt new file mode 100644 index 0000000..d53e4c2 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.exception.run + +abstract class RuntimeError { + abstract val message: String +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt index bf63c2c..8570525 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt @@ -1,6 +1,6 @@ package com.infendro.cli.exception.run -class UnexpectedArgument : RunException() { +class UnexpectedArgument : RuntimeError() { override val message: String get() = """unexpected argument""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt index 4fb1117..2474927 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt @@ -2,7 +2,7 @@ package com.infendro.cli.exception.run class UnknownCommand( val command: String, -) : RunException() { +) : RuntimeError() { override val message: String get() = """unknown command "$command"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt index 38c9a41..ce27c2b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt @@ -2,7 +2,7 @@ package com.infendro.cli.exception.run class UnknownOption( val option: String, -) : RunException() { +) : RuntimeError() { override val message: String get() = """unknown option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt index d699bf1..2389e45 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -1,14 +1,10 @@ package com.infendro.cli.parser -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 -> invalid(text) - } +object BooleanParser : Parser(Boolean::class) { + override fun parse(text: String) = + when (text) { + "true", "t" -> success(true) + "false", "f" -> success(false) + else -> failure(text) + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt index f0286c7..2d1215b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -1,11 +1,8 @@ package com.infendro.cli.parser -import kotlin.reflect.KClass - -object DoubleParser : Parser { - override val type: KClass - get() = Double::class - - override fun parse(text: String): Double = text.toDoubleOrNull() - ?: invalid(text) +object DoubleParser : Parser(Double::class) { + override fun parse(text: String) = + text.toDoubleOrNull() + ?.let { success(it) } + ?: failure(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt index bf9448f..5ee0b74 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -4,11 +4,13 @@ import kotlin.enums.enumEntries import kotlin.reflect.KClass class EnumParser>( - override val type: KClass, + type: KClass, private val values: List, -) : Parser { - override fun parse(text: String): T = values.firstOrNull { it.name == text } - ?: invalid(text) +) : Parser(type) { + override fun parse(text: String) = + values.firstOrNull { it.name == text } + ?.let { success(it) } + ?: failure(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 aa3c3e2..c41f620 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -1,11 +1,8 @@ package com.infendro.cli.parser -import kotlin.reflect.KClass - -object FloatParser : Parser { - override val type: KClass - get() = Float::class - - override fun parse(text: String): Float = text.toFloatOrNull() - ?: invalid(text) +object FloatParser : Parser(Float::class) { + override fun parse(text: String) = + text.toFloatOrNull() + ?.let { success(it) } + ?: failure(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt index 570c7bd..3bf23ef 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -1,11 +1,8 @@ package com.infendro.cli.parser -import kotlin.reflect.KClass - -object IntParser : Parser { - override val type: KClass - get() = Int::class - - override fun parse(text: String): Int = text.toIntOrNull() - ?: invalid(text) +object IntParser : Parser(Int::class) { + override fun parse(text: String) = + text.toIntOrNull() + ?.let { success(it) } + ?: failure(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt index 4e8319b..4f218aa 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -1,11 +1,8 @@ package com.infendro.cli.parser -import kotlin.reflect.KClass - -object LongParser : Parser { - override val type: KClass - get() = Long::class - - override fun parse(text: String): Long = text.toLongOrNull() - ?: invalid(text) +object LongParser : Parser(Long::class) { + override fun parse(text: String) = + text.toLongOrNull() + ?.let { success(it) } + ?: failure(text) } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt index ee9dfa2..d2bfe1b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -1,12 +1,24 @@ package com.infendro.cli.parser import com.infendro.cli.exception.run.InvalidValue +import com.infendro.cli.exception.run.RuntimeError import kotlin.reflect.KClass -interface Parser { - val type: KClass +abstract class Parser( + val type: KClass, +) { + sealed class Result { + data class Success( + val value: T, + ) : Result() - fun parse(text: String): T + data class Failure( + val error: RuntimeError, + ) : Result() + } - fun invalid(text: String): Nothing = throw InvalidValue(text, type) + protected fun success(value: T) = Result.Success(value) + protected fun failure(text: String) = Result.Failure(InvalidValue(text, type)) + + abstract fun parse(text: String): Result } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt index be7f9b3..ebf2f24 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -1,10 +1,6 @@ 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 +object StringParser : Parser(String::class) { + override fun parse(text: String) = + success(text) } From 2a9f086c9d79a70daef8428ab73427fc18f8f7a1 Mon Sep 17 00:00:00 2001 From: Infendro Date: Wed, 7 Jan 2026 22:00:32 +0100 Subject: [PATCH 12/20] improve exceptions --- .../kotlin/com/infendro/cli/command/Command.kt | 12 +++--------- .../com/infendro/cli/command/argument/Argument.kt | 9 +++++++-- .../infendro/cli/command/context/ContextParser.kt | 6 +++--- .../infendro/cli/command/help/DefaultHelpRenderer.kt | 6 +++++- .../kotlin/com/infendro/cli/command/option/Option.kt | 7 +++++-- .../com/infendro/cli/command/option/OptionFactory.kt | 2 +- .../com/infendro/cli/error/build/BuildException.kt | 3 +++ .../com/infendro/cli/error/build/DuplicateCommand.kt | 8 ++++++++ .../com/infendro/cli/error/build/DuplicateExecute.kt | 6 ++++++ .../com/infendro/cli/error/build/DuplicateOption.kt | 8 ++++++++ .../infendro/cli/error/build/DuplicateRenderer.kt | 6 ++++++ .../com/infendro/cli/error/build/InvalidArgument.kt | 8 ++++++++ .../infendro/cli/error/build/InvalidArgumentOrder.kt | 6 ++++++ .../com/infendro/cli/error/build/InvalidCommand.kt | 8 ++++++++ .../infendro/cli/error/build/InvalidCommandOrder.kt | 6 ++++++ .../com/infendro/cli/error/build/InvalidFallback.kt | 6 ++++++ .../com/infendro/cli/error/build/InvalidOption.kt | 8 ++++++++ .../com/infendro/cli/error/build/InvalidRange.kt | 6 ++++++ .../infendro/cli/error/build/MissingOptionName.kt | 6 ++++++ .../{exception => error}/run/InvalidArgumentCount.kt | 4 ++-- .../{exception => error}/run/InvalidOptionCount.kt | 4 ++-- .../cli/{exception => error}/run/InvalidValue.kt | 4 ++-- .../cli/{exception => error}/run/MalformedOption.kt | 4 ++-- .../{exception => error}/run/MissingOptionValue.kt | 4 ++-- .../kotlin/com/infendro/cli/error/run/RunError.kt | 5 +++++ .../com/infendro/cli/error/run/UnexpectedArgument.kt | 6 ++++++ .../cli/{exception => error}/run/UnknownCommand.kt | 4 ++-- .../cli/{exception => error}/run/UnknownOption.kt | 4 ++-- .../com/infendro/cli/exception/build/CliException.kt | 3 --- .../infendro/cli/exception/build/DuplicateCommand.kt | 5 ----- .../infendro/cli/exception/build/DuplicateExecute.kt | 3 --- .../infendro/cli/exception/build/DuplicateOption.kt | 5 ----- .../infendro/cli/exception/build/InvalidArgument.kt | 5 ----- .../cli/exception/build/InvalidArgumentOrder.kt | 3 --- .../infendro/cli/exception/build/InvalidCommand.kt | 5 ----- .../cli/exception/build/InvalidCommandOrder.kt | 3 --- .../infendro/cli/exception/build/InvalidFallback.kt | 3 --- .../infendro/cli/exception/build/InvalidOption.kt | 5 ----- .../com/infendro/cli/exception/build/InvalidRange.kt | 3 --- .../cli/exception/build/MissingOptionName.kt | 3 --- .../com/infendro/cli/exception/run/RuntimeError.kt | 5 ----- .../infendro/cli/exception/run/UnexpectedArgument.kt | 6 ------ .../kotlin/com/infendro/cli/parser/Parser.kt | 6 +++--- 43 files changed, 137 insertions(+), 92 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateExecute.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateRenderer.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentOrder.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandOrder.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidFallback.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/InvalidRange.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/MissingOptionName.kt rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/InvalidArgumentCount.kt (90%) rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/InvalidOptionCount.kt (89%) rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/InvalidValue.kt (78%) rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/MalformedOption.kt (68%) rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/MissingOptionValue.kt (77%) create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/UnknownCommand.kt (68%) rename src/commonMain/kotlin/com/infendro/cli/{exception => error}/run/UnknownOption.kt (68%) delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt delete mode 100644 src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index b0ae597..1690d9f 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -7,11 +7,9 @@ import com.infendro.cli.command.context.ContextParser import com.infendro.cli.command.help.DefaultHelpRenderer import com.infendro.cli.command.help.HelpRenderer import com.infendro.cli.command.option.Option -import com.infendro.cli.exception.build.* +import com.infendro.cli.error.build.* import com.infendro.cli.parser.* import com.infendro.cli.util.Regex -import com.infendro.cli.util.Regex.ARGUMENT -import com.infendro.cli.util.Regex.OPTION import kotlin.reflect.KProperty sealed class Command private constructor( @@ -205,15 +203,11 @@ sealed class Command private constructor( argument.required && last.optional -> throw InvalidArgumentOrder() } } - if (!argument.name.matches(ARGUMENT)) throw InvalidArgument(argument.name) } private fun validateOption(option: Option<*>) { for (name in option.names) { - when { - !name.matches(OPTION) -> throw InvalidOption(name) - options.any { name in it.names } -> throw DuplicateOption(name) - } + if (options.any { name in it.names }) throw DuplicateOption(name) } } @@ -222,7 +216,7 @@ sealed class Command private constructor( } private fun validateRenderer() { - if (_renderer != null) throw Exception() //TODO + if (_renderer != null) throw DuplicateRenderer() } private fun validate() { 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 489d35c..3a63920 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt @@ -1,7 +1,9 @@ package com.infendro.cli.command.argument -import com.infendro.cli.exception.build.InvalidRange +import com.infendro.cli.error.build.InvalidArgument +import com.infendro.cli.error.build.InvalidRange import com.infendro.cli.parser.Parser +import com.infendro.cli.util.Regex.ARGUMENT sealed class Argument( val parser: Parser, @@ -10,7 +12,10 @@ sealed class Argument( val max: Int?, ) { init { - if (min !in 0..count || max == 0) throw InvalidRange() + when { + !name.matches(ARGUMENT) -> throw InvalidArgument(name) + min !in 0..count || max == 0 -> throw InvalidRange() + } } val required: Boolean diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index 07b6795..4caf619 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -1,7 +1,7 @@ package com.infendro.cli.command.context import com.infendro.cli.command.Command -import com.infendro.cli.exception.run.* +import com.infendro.cli.error.run.* import com.infendro.cli.parser.Parser internal class ContextParser( @@ -19,13 +19,13 @@ internal class ContextParser( data class Failure( val command: Command, - val error: RuntimeError, + val error: RunError, ) : 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 fun failure(command: Command, exception: RunError) = Result.Failure(command, exception) private var index = 0 private val current: String 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 36bed08..76e4b5b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -75,7 +75,11 @@ object DefaultHelpRenderer : HelpRenderer { private fun StringBuilder.commands(commands: List) { appendLine("Commands:") for (command in commands) { - appendLine(" * ${command.name}") + val name = when { + command is Command.Fallback<*> -> "<${command.name}>" + else -> command.name + } + appendLine(" * $name") } } 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 ac3711a..5191848 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt @@ -1,8 +1,10 @@ package com.infendro.cli.command.option -import com.infendro.cli.exception.build.InvalidRange -import com.infendro.cli.exception.build.MissingOptionName +import com.infendro.cli.error.build.InvalidOption +import com.infendro.cli.error.build.InvalidRange +import com.infendro.cli.error.build.MissingOptionName import com.infendro.cli.parser.Parser +import com.infendro.cli.util.Regex.OPTION sealed class Option( val parser: Parser, @@ -33,6 +35,7 @@ sealed class Option( init { when { names.isEmpty() -> throw MissingOptionName() + names.any { !it.matches(OPTION) } -> throw InvalidOption(name) min !in 0..count || max == 0 -> throw InvalidRange() } } 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 82ceaab..95924bf 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt @@ -8,7 +8,7 @@ object OptionFactory { 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, true, *names) + fun boolean(vararg names: String) = option(BooleanParser, fallback = true, *names) inline fun > enum(vararg names: String) = option(enumParser(), *names) } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt new file mode 100644 index 0000000..ecefba9 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt @@ -0,0 +1,3 @@ +package com.infendro.cli.error.build + +abstract class BuildException : Error() diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt new file mode 100644 index 0000000..354ce41 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class DuplicateCommand( + val command: String, +) : BuildException() { + override val message: String + get() = """duplicate command "$command"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateExecute.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateExecute.kt new file mode 100644 index 0000000..f52c38d --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateExecute.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class DuplicateExecute : BuildException() { + override val message: String + get() = """duplicate execute block""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt new file mode 100644 index 0000000..adf89a2 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class DuplicateOption( + val name: String, +) : BuildException() { + override val message: String + get() = """duplicate option "$name"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateRenderer.kt new file mode 100644 index 0000000..347b803 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateRenderer.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class DuplicateRenderer : BuildException() { + override val message: String + get() = """duplicate renderer""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt new file mode 100644 index 0000000..6d7ae74 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidArgument( + val name: String, +) : BuildException() { + override val message: String + get() = """invalid argument name "$name"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentOrder.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentOrder.kt new file mode 100644 index 0000000..f5b4b52 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentOrder.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class InvalidArgumentOrder : BuildException() { + override val message: String + get() = """invalid argument order""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt new file mode 100644 index 0000000..a6d3a3a --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidCommand( + val name: String, +) : BuildException() { + override val message: String + get() = """invalid command name "$name"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandOrder.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandOrder.kt new file mode 100644 index 0000000..eb65123 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandOrder.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class InvalidCommandOrder : BuildException() { + override val message: String + get() = """invalid command order""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidFallback.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidFallback.kt new file mode 100644 index 0000000..ac0c01a --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidFallback.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class InvalidFallback : BuildException() { + override val message: String + get() = """invalid fallback""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt new file mode 100644 index 0000000..896d5df --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidOption( + val name: String, +) : BuildException() { + override val message: String + get() = """invalid option name "$name"""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidRange.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidRange.kt new file mode 100644 index 0000000..f95e8df --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidRange.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class InvalidRange : BuildException() { + override val message: String + get() = """invalid range""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/MissingOptionName.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/MissingOptionName.kt new file mode 100644 index 0000000..a7997fd --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/MissingOptionName.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.build + +class MissingOptionName : BuildException() { + override val message: String + get() = """missing option name""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt similarity index 90% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt index 661b4c6..eee6b47 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidArgumentCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt @@ -1,11 +1,11 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run import com.infendro.cli.command.argument.Argument class InvalidArgumentCount( val argument: Argument<*>, val count: Int, -) : RuntimeError() { +) : RunError() { override val message: String get() { val expected = when { diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt similarity index 89% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt index 8be30ba..7d85c92 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidOptionCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt @@ -1,11 +1,11 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run import com.infendro.cli.command.option.Option class InvalidOptionCount( val option: Option<*>, val count: Int, -) : RuntimeError() { +) : RunError() { override val message: String get() { val expected = when { diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt similarity index 78% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt index 40285a0..957308d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt @@ -1,11 +1,11 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run import kotlin.reflect.KClass class InvalidValue( val value: String, val type: KClass<*>, -) : RuntimeError() { +) : RunError() { override val message: String get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt similarity index 68% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt index b107348..bf3f09e 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MalformedOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt @@ -1,8 +1,8 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run class MalformedOption( val option: String, -) : RuntimeError() { +) : RunError() { override val message: String get() = """malformed option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt similarity index 77% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt index e7f6bb9..3e66f4b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingOptionValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt @@ -1,10 +1,10 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run import com.infendro.cli.command.option.Option class MissingOptionValue( val option: Option<*>, -) : RuntimeError() { +) : RunError() { override val message: String get() = """no value provided for option "${option.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt new file mode 100644 index 0000000..b273109 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.error.run + +abstract class RunError { + abstract val message: String +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt new file mode 100644 index 0000000..ab04ef7 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.error.run + +class UnexpectedArgument : RunError() { + override val message: String + get() = """unexpected argument""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt similarity index 68% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt index 2474927..69903c4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt @@ -1,8 +1,8 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run class UnknownCommand( val command: String, -) : RuntimeError() { +) : RunError() { override val message: String get() = """unknown command "$command"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt similarity index 68% rename from src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt index ce27c2b..1ccee98 100644 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt @@ -1,8 +1,8 @@ -package com.infendro.cli.exception.run +package com.infendro.cli.error.run class UnknownOption( val option: String, -) : RuntimeError() { +) : RunError() { override val message: String get() = """unknown option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt deleted file mode 100644 index 1f666d7..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/CliException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -abstract class CliException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt deleted file mode 100644 index fbddcd7..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateCommand.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateCommand( - val command: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt deleted file mode 100644 index 818a437..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateExecute.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateExecute : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt deleted file mode 100644 index 687e9ea..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/DuplicateOption.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class DuplicateOption( - val name: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt deleted file mode 100644 index ad901b4..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgument.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidArgument( - val name: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt deleted file mode 100644 index 6e2a498..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidArgumentOrder.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidArgumentOrder : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt deleted file mode 100644 index f49aacd..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommand.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidCommand( - val name: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt deleted file mode 100644 index 4c35cf4..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidCommandOrder.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidCommandOrder : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt deleted file mode 100644 index cc50110..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidFallback.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidFallback : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt deleted file mode 100644 index f336e4d..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidOption.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidOption( - val name: String, -) : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt deleted file mode 100644 index 23eec23..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/InvalidRange.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class InvalidRange : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt deleted file mode 100644 index 8725a87..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/MissingOptionName.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class MissingOptionName : CliException() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt deleted file mode 100644 index d53e4c2..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/RuntimeError.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.run - -abstract class RuntimeError { - abstract val message: String -} diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt deleted file mode 100644 index 8570525..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnexpectedArgument.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.infendro.cli.exception.run - -class UnexpectedArgument : RuntimeError() { - override val message: String - get() = """unexpected argument""" -} diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt index d2bfe1b..4541b0d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -1,7 +1,7 @@ package com.infendro.cli.parser -import com.infendro.cli.exception.run.InvalidValue -import com.infendro.cli.exception.run.RuntimeError +import com.infendro.cli.error.run.InvalidValue +import com.infendro.cli.error.run.RunError import kotlin.reflect.KClass abstract class Parser( @@ -13,7 +13,7 @@ abstract class Parser( ) : Result() data class Failure( - val error: RuntimeError, + val error: RunError, ) : Result() } From 6aa6c1b3c86ece485da040ca2d9e1828780a9a8b Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 18:53:54 +0100 Subject: [PATCH 13/20] improve command handling, change argument/option registration --- README.md | 22 ++-- .../com/infendro/cli/command/Command.kt | 112 +++++------------- .../infendro/cli/command/CommandFactory.kt | 39 ++++++ .../infendro/cli/command/context/Context.kt | 7 +- .../cli/command/context/ContextParser.kt | 36 +++--- .../cli/command/help/DefaultHelpRenderer.kt | 12 +- .../infendro/cli/command/help/HelpRenderer.kt | 2 +- .../cli/command/help/NoopHelpRenderer.kt | 2 +- .../cli/error/build/DuplicateArgument.kt | 8 ++ 9 files changed, 123 insertions(+), 117 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt create mode 100644 src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt diff --git a/README.md b/README.md index f999f46..48774e0 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,20 @@ dependencies { ## Usage ```kotlin -fun main(args: Array) = cli("application") { +fun main(args: Array) = command("application") { execute { // define execution of command (e.g., display help) help() } - // declare command "greet" - command("greet") { - // declare arguments - val speakersArg by argument.string("speakers").variable(min = 1) + // register command "greet" + +command("greet") { + // declare and register arguments + val speakersArg = +argument.string("speakers").variable(min = 1) - // declare options - val greetingOpt by option.string("greeting", "greet", "g").orElse("Hello") - val nameOpt by option.string("name", "n").orNull() + // declare and register options + val greetingOpt = +option.string("greeting", "greet", "g").orElse("Hello") + val nameOpt = +option.string("name", "n").orNull() execute { // retrieve the arguments and options @@ -52,11 +52,11 @@ fun main(args: Array) = cli("application") { println("${speakers.joinToString()}: $greeting ${name ?: "World"}!") } - // declare more commands... + // register more commands... } - // declare fallback command - fallback.string("value") { valueArg -> + // register fallback command "value" + +command.fallback.string("value") { 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 1690d9f..d10601f 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -8,12 +8,10 @@ import com.infendro.cli.command.help.DefaultHelpRenderer import com.infendro.cli.command.help.HelpRenderer import com.infendro.cli.command.option.Option import com.infendro.cli.error.build.* -import com.infendro.cli.parser.* +import com.infendro.cli.parser.Parser import com.infendro.cli.util.Regex -import kotlin.reflect.KProperty sealed class Command private constructor( - val parent: Command?, val name: String, val commands: List, val arguments: List>, @@ -21,22 +19,21 @@ sealed class Command private constructor( internal val execute: Context.() -> Unit, internal val renderer: HelpRenderer, ) { - val path: List - get() = when { - parent == null -> listOf(this) - else -> parent.path + this - } - - val help: String? - get() = renderer.render(this) + init { + if (!name.matches(Regex.COMMAND)) throw InvalidCommand(name) + } fun run(args: Array) { when (val result = ContextParser(this, args).parse()) { is ContextParser.Result.Success -> result.context.execute() - is ContextParser.Result.Help -> result.command.help?.let { print(it) } + is ContextParser.Result.Help -> { + val command = result.path.last() + command.renderer.render(result.path)?.let { print(it) } + } is ContextParser.Result.Failure -> { println("error: ${result.error.message}") - result.command.help?.let { + val command = result.path.last() + command.renderer.render(result.path)?.let { println() print(it) } @@ -45,24 +42,22 @@ sealed class Command private constructor( } class Named( - parent: Command?, name: String, commands: List, arguments: List>, options: List>, execute: Context.() -> Unit, renderer: HelpRenderer, - ) : Command(parent, name, commands, arguments, options, execute, renderer) { + ) : Command(name, commands, arguments, options, execute, renderer) { class Builder( name: String, ) : Command.Builder(name) { override fun create() = - Named(parent, name, commands, arguments, options, execute, renderer) + Named(name, commands, arguments, options, execute, renderer) } } class Fallback( - parent: Command?, val key: Key, val parser: Parser, name: String, @@ -71,7 +66,7 @@ sealed class Command private constructor( options: List>, execute: Context.() -> Unit, renderer: HelpRenderer, - ) : Command(parent, name, commands, arguments, options, execute, renderer) { + ) : Command(name, commands, arguments, options, execute, renderer) { class Builder( val parser: Parser, name: String, @@ -79,7 +74,7 @@ sealed class Command private constructor( internal val key = Key() override fun create() = - Fallback(parent, key, parser, name, commands, arguments, options, execute, renderer) + Fallback(key, parser, name, commands, arguments, options, execute, renderer) } class Key @@ -89,8 +84,6 @@ sealed class Command private constructor( sealed class Builder( protected val name: String, ) { - protected var parent: Command? = null - private val builders = mutableListOf>() protected val commands = mutableListOf() protected val arguments = mutableListOf>() protected val options = mutableListOf>() @@ -102,40 +95,13 @@ sealed class Command private constructor( protected val renderer: HelpRenderer get() = _renderer!! - fun command(name: String, block: Builder<*>.() -> Unit) { - validateCommand(name) - builders += Named.Builder(name).apply(block) + fun register(command: Command) { + validateCommand(command) + commands += command } - inner class FallbackFactory { - fun string(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(StringParser, name, block) - - fun int(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(IntParser, name, block) - - fun long(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(LongParser, name, block) - - fun float(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(FloatParser, name, block) - - fun double(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(DoubleParser, name, block) - - fun boolean(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(BooleanParser, name, block) - - inline fun > enum(name: String, noinline block: Builder<*>.(Fallback.Key) -> Unit) = - fallback(enumParser(), name, block) - } - - val fallback = FallbackFactory() - - fun fallback(parser: Parser, name: String, block: Fallback.Builder.(Fallback.Key) -> Unit) { - validateCommand(name) - builders += Fallback.Builder(parser, name).apply { block(key) } - } + fun register(vararg commands: Command) = commands.forEach(::register) + operator fun T.unaryPlus() = also(::register) fun register(argument: Argument<*>) { validateArgument(argument) @@ -143,9 +109,7 @@ sealed class Command private constructor( } fun register(vararg arguments: Argument<*>) = arguments.forEach(::register) - - operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register) - operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this + operator fun > T.unaryPlus() = also(::register) fun register(option: Option<*>) { validateOption(option) @@ -153,9 +117,7 @@ sealed class Command private constructor( } fun register(vararg options: Option<*>) = options.forEach(::register) - - operator fun > T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register) - operator fun > T.getValue(thisRef: Nothing?, property: KProperty<*>) = this + operator fun > T.unaryPlus() = also(::register) fun execute(block: Context.() -> Unit) { validateExecute() @@ -167,32 +129,22 @@ sealed class Command private constructor( _renderer = renderer } + internal abstract fun create(): COMMAND + internal fun build(): Command { if (_execute == null) _execute = {} if (_renderer == null) _renderer = DefaultHelpRenderer validate() - val command = create() - - for (builder in builders) { - builder.parent = command - commands += builder.build() - } - - return command + return create() } - internal abstract fun create(): COMMAND - - private fun validateCommand(name: String) { - if (builders.isNotEmpty()) { - val last = builders.last() - if (last is Fallback.Builder<*>) throw InvalidCommandOrder() - } - when { - !name.matches(Regex.COMMAND) -> throw InvalidCommand(name) - builders.any { it.name == name } -> throw DuplicateCommand(name) + private fun validateCommand(command: Command) { + if (commands.isNotEmpty()) { + val last = commands.last() + if (last is Fallback<*>) throw InvalidCommandOrder() } + if (commands.any { it.name == command.name }) throw DuplicateCommand(command.name) } private fun validateArgument(argument: Argument<*>) { @@ -203,6 +155,7 @@ sealed class Command private constructor( argument.required && last.optional -> throw InvalidArgumentOrder() } } + if (arguments.any { it.name == argument.name }) throw DuplicateArgument(argument.name) } private fun validateOption(option: Option<*>) { @@ -220,10 +173,7 @@ sealed class Command private constructor( } private fun validate() { - if (builders.any { it is Fallback.Builder<*> } && arguments.isNotEmpty()) throw InvalidFallback() + if (commands.any { it is Fallback<*> } && arguments.isNotEmpty()) throw InvalidFallback() } } } - -fun cli(name: String, block: Command.Builder<*>.() -> Unit) = - Command.Named.Builder(name).apply(block).build() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt b/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt new file mode 100644 index 0000000..bef9ef8 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt @@ -0,0 +1,39 @@ +package com.infendro.cli.command + +import com.infendro.cli.command.Command.* +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 int(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(IntParser, name, block) + + fun long(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(LongParser, name, block) + + fun float(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(FloatParser, name, block) + + fun double(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(DoubleParser, name, block) + + fun boolean(name: String, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(BooleanParser, name, block) + + inline fun > enum(name: String, noinline block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(enumParser(), name, block) + } + + fun fallback(parser: Parser, name: String, block: Fallback.Builder.(Fallback.Key) -> Unit) = + Fallback.Builder(parser, name).apply { block(key) }.build() + + val fallback = FallbackFactory() +} + +fun command(name: String, block: Builder<*>.() -> Unit) = + Named.Builder(name).apply(block).build() + +val command = CommandFactory() 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 5084cb0..9f1c866 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -8,15 +8,18 @@ import kotlin.reflect.KProperty @Dsl class Context internal constructor( - val command: Command, + private val path: List, private val commands: Map, Any>, private val arguments: Map, List>, private val options: Map, List>, ) { + val command: Command + get() = path.last() + internal fun execute() = (command.execute)() fun help() { - command.help?.let { print(it) } + command.renderer.render(path)?.let { print(it) } } @Suppress("UNCHECKED_CAST") diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index 4caf619..04d4e5c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -14,18 +14,18 @@ internal class ContextParser( ) : Result() data class Help( - val command: Command, + val path: List, ) : Result() data class Failure( - val command: Command, + val path: List, val error: RunError, ) : Result() } private fun success(context: Context) = Result.Success(context) - private fun help(command: Command) = Result.Help(command) - private fun failure(command: Command, exception: RunError) = Result.Failure(command, exception) + private fun help(path: List) = Result.Help(path) + private fun failure(path: List, exception: RunError) = Result.Failure(path, exception) private var index = 0 private val current: String @@ -41,6 +41,7 @@ internal class ContextParser( fun parse(): Result { // command var command = root + val path = mutableListOf(root) val commands = mutableMapOf, Any>() while (hasNext()) { @@ -52,16 +53,17 @@ internal class ContextParser( } if (cmd == null) { when { - command.arguments.isEmpty() -> return failure(command, UnknownCommand(current)) + command.arguments.isEmpty() -> return failure(path, UnknownCommand(current)) else -> break } } command = cmd + path += 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) + is Parser.Result.Failure<*> -> return failure(path, result.error) } commands[cmd.key] = value } @@ -85,11 +87,11 @@ internal class ContextParser( // argument endOfOptions || dashes == 0 -> { val argument = command.arguments.getOrNull(argumentIndex) - ?: return failure(command, UnexpectedArgument()) + ?: return failure(path, 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) + is Parser.Result.Failure<*> -> return failure(path, result.error) } arguments[argument]!! += value argumentCount++ @@ -109,7 +111,7 @@ internal class ContextParser( } if (dashes == 2 && trimmed == "help") - return help(command) + return help(path) val (name, text) = when { trimmed.contains('=') -> { @@ -133,16 +135,16 @@ internal class ContextParser( for (name in names) { val option = command.options.firstOrNull { name in it.names } - ?: return failure(command, UnknownOption(name)) + ?: return failure(path, 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) + is Parser.Result.Failure<*> -> return failure(path, result.error) } option.flag -> option.fallback!! - else -> return failure(command, MissingOptionValue(option)) + else -> return failure(path, MissingOptionValue(option)) } options[option]!! += value } @@ -153,9 +155,9 @@ internal class ContextParser( when { current.contains('=') -> { val (option) = current.split('=', limit = 2) - return failure(command, MalformedOption(option)) + return failure(path, MalformedOption(option)) } - else -> return failure(command, MalformedOption(current)) + else -> return failure(path, MalformedOption(current)) } } } @@ -166,16 +168,16 @@ internal class ContextParser( // 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)) + if (count !in argument.min..argument.count) return failure(path, 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)) + if (count !in option.min..option.count) return failure(path, InvalidOptionCount(option, count)) } - val context = Context(command, commands, arguments, options) + val context = Context(path, commands, arguments, options) 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 index 76e4b5b..50a105a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -5,8 +5,10 @@ import com.infendro.cli.command.argument.Argument import com.infendro.cli.command.option.Option object DefaultHelpRenderer : HelpRenderer { - override fun render(command: Command) = buildString { - usage(command) + override fun render(path: List) = buildString { + val command = path.last() + + usage(path) if (command.commands.isNotEmpty()) { appendLine() commands(command.commands) @@ -21,9 +23,11 @@ object DefaultHelpRenderer : HelpRenderer { } } - private fun StringBuilder.usage(command: Command) { + private fun StringBuilder.usage(path: List) { + val command = path.last() + append("Usage:") - for (command in command.path) { + for (command in path) { append(" ") when { command is Command.Fallback<*> -> append("<${command.name}>") diff --git a/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt index 62a701c..d923c5b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt @@ -3,5 +3,5 @@ package com.infendro.cli.command.help import com.infendro.cli.command.Command interface HelpRenderer { - fun render(command: Command): String? + fun render(path: List): 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 index f92b64e..5e6c09b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/NoopHelpRenderer.kt @@ -3,5 +3,5 @@ package com.infendro.cli.command.help import com.infendro.cli.command.Command object NoopHelpRenderer : HelpRenderer { - override fun render(command: Command) = null + override fun render(path: List) = null } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt new file mode 100644 index 0000000..fe21142 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class DuplicateArgument( + val name: String, +) : BuildException() { + override val message: String + get() = """duplicate argument "$name"""" +} From f33caa30c04e7af7f82ee0123b05b07bcfdc9935 Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 19:42:48 +0100 Subject: [PATCH 14/20] improve exceptions --- src/commonMain/kotlin/com/infendro/cli/command/Command.kt | 2 +- .../kotlin/com/infendro/cli/command/argument/Argument.kt | 4 ++-- .../kotlin/com/infendro/cli/command/option/Option.kt | 4 ++-- .../kotlin/com/infendro/cli/error/build/BuildException.kt | 2 +- .../com/infendro/cli/error/build/DuplicateArgument.kt | 6 ++++-- .../kotlin/com/infendro/cli/error/build/DuplicateCommand.kt | 6 ++++-- .../kotlin/com/infendro/cli/error/build/DuplicateOption.kt | 6 ++++-- .../build/{InvalidArgument.kt => InvalidArgumentName.kt} | 2 +- .../build/{InvalidCommand.kt => InvalidCommandName.kt} | 2 +- .../error/build/{InvalidOption.kt => InvalidOptionName.kt} | 2 +- 10 files changed, 21 insertions(+), 15 deletions(-) rename src/commonMain/kotlin/com/infendro/cli/error/build/{InvalidArgument.kt => InvalidArgumentName.kt} (86%) rename src/commonMain/kotlin/com/infendro/cli/error/build/{InvalidCommand.kt => InvalidCommandName.kt} (86%) rename src/commonMain/kotlin/com/infendro/cli/error/build/{InvalidOption.kt => InvalidOptionName.kt} (87%) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index d10601f..5cfb1df 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -20,7 +20,7 @@ sealed class Command private constructor( internal val renderer: HelpRenderer, ) { init { - if (!name.matches(Regex.COMMAND)) throw InvalidCommand(name) + if (!name.matches(Regex.COMMAND)) throw InvalidCommandName(name) } fun run(args: Array) { 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 3a63920..696b794 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt @@ -1,6 +1,6 @@ package com.infendro.cli.command.argument -import com.infendro.cli.error.build.InvalidArgument +import com.infendro.cli.error.build.InvalidArgumentName import com.infendro.cli.error.build.InvalidRange import com.infendro.cli.parser.Parser import com.infendro.cli.util.Regex.ARGUMENT @@ -13,7 +13,7 @@ sealed class Argument( ) { init { when { - !name.matches(ARGUMENT) -> throw InvalidArgument(name) + !name.matches(ARGUMENT) -> throw InvalidArgumentName(name) min !in 0..count || max == 0 -> throw InvalidRange() } } 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 5191848..7073274 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt @@ -1,6 +1,6 @@ package com.infendro.cli.command.option -import com.infendro.cli.error.build.InvalidOption +import com.infendro.cli.error.build.InvalidOptionName import com.infendro.cli.error.build.InvalidRange import com.infendro.cli.error.build.MissingOptionName import com.infendro.cli.parser.Parser @@ -35,7 +35,7 @@ sealed class Option( init { when { names.isEmpty() -> throw MissingOptionName() - names.any { !it.matches(OPTION) } -> throw InvalidOption(name) + names.any { !it.matches(OPTION) } -> throw InvalidOptionName(name) min !in 0..count || max == 0 -> throw InvalidRange() } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt index ecefba9..0c1af10 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/BuildException.kt @@ -1,3 +1,3 @@ package com.infendro.cli.error.build -abstract class BuildException : Error() +abstract class BuildException : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt index fe21142..5f3bbab 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt @@ -1,8 +1,10 @@ package com.infendro.cli.error.build +import com.infendro.cli.command.argument.Argument + class DuplicateArgument( - val name: String, + val argument: Argument<*>, ) : BuildException() { override val message: String - get() = """duplicate argument "$name"""" + get() = """duplicate argument "${argument.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt index 354ce41..4919bb0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt @@ -1,8 +1,10 @@ package com.infendro.cli.error.build +import com.infendro.cli.command.Command + class DuplicateCommand( - val command: String, + val command: Command, ) : BuildException() { override val message: String - get() = """duplicate command "$command"""" + get() = """duplicate command "${command.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt index adf89a2..52f3c3b 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt @@ -1,8 +1,10 @@ package com.infendro.cli.error.build +import com.infendro.cli.command.option.Option + class DuplicateOption( - val name: String, + val option: Option<*>, ) : BuildException() { override val message: String - get() = """duplicate option "$name"""" + get() = """duplicate option "${option.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentName.kt similarity index 86% rename from src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt rename to src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentName.kt index 6d7ae74..8a9a3d5 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentName.kt @@ -1,6 +1,6 @@ package com.infendro.cli.error.build -class InvalidArgument( +class InvalidArgumentName( val name: String, ) : BuildException() { override val message: String diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandName.kt similarity index 86% rename from src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt rename to src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandName.kt index a6d3a3a..e1fd029 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandName.kt @@ -1,6 +1,6 @@ package com.infendro.cli.error.build -class InvalidCommand( +class InvalidCommandName( val name: String, ) : BuildException() { override val message: String diff --git a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOptionName.kt similarity index 87% rename from src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt rename to src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOptionName.kt index 896d5df..1eeaa0d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOptionName.kt @@ -1,6 +1,6 @@ package com.infendro.cli.error.build -class InvalidOption( +class InvalidOptionName( val name: String, ) : BuildException() { override val message: String From 7001b00f5f46e91011f24d7bdd9113f86dfa2a64 Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 21:09:04 +0100 Subject: [PATCH 15/20] improve help display type for fallback commands display type and range for arguments/options --- .../com/infendro/cli/command/Command.kt | 10 ++-- .../cli/command/help/DefaultHelpRenderer.kt | 47 ++++++++++++++----- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 5cfb1df..0faa25e 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -23,8 +23,8 @@ sealed class Command private constructor( if (!name.matches(Regex.COMMAND)) throw InvalidCommandName(name) } - fun run(args: Array) { - when (val result = ContextParser(this, args).parse()) { + fun run(vararg args: String) { + when (val result = ContextParser(this, arrayOf(*args)).parse()) { is ContextParser.Result.Success -> result.context.execute() is ContextParser.Result.Help -> { val command = result.path.last() @@ -144,7 +144,7 @@ sealed class Command private constructor( val last = commands.last() if (last is Fallback<*>) throw InvalidCommandOrder() } - if (commands.any { it.name == command.name }) throw DuplicateCommand(command.name) + if (commands.any { it.name == command.name }) throw DuplicateCommand(command) } private fun validateArgument(argument: Argument<*>) { @@ -155,12 +155,12 @@ sealed class Command private constructor( argument.required && last.optional -> throw InvalidArgumentOrder() } } - if (arguments.any { it.name == argument.name }) throw DuplicateArgument(argument.name) + if (arguments.any { it.name == argument.name }) throw DuplicateArgument(argument) } private fun validateOption(option: Option<*>) { for (name in option.names) { - if (options.any { name in it.names }) throw DuplicateOption(name) + if (options.any { name in it.names }) throw DuplicateOption(option) } } 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 50a105a..d1027cb 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -3,6 +3,7 @@ package com.infendro.cli.command.help import com.infendro.cli.command.Command import com.infendro.cli.command.argument.Argument import com.infendro.cli.command.option.Option +import kotlin.reflect.KClass object DefaultHelpRenderer : HelpRenderer { override fun render(path: List) = buildString { @@ -40,11 +41,7 @@ object DefaultHelpRenderer : HelpRenderer { 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}}" - } + val range = renderRange(argument.min, argument.max) append("<${argument.name}>$range") } } @@ -64,11 +61,7 @@ object DefaultHelpRenderer : HelpRenderer { 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}}" - } + val range = renderRange(option.min, option.max) append("($text)$range") } } @@ -80,7 +73,11 @@ object DefaultHelpRenderer : HelpRenderer { appendLine("Commands:") for (command in commands) { val name = when { - command is Command.Fallback<*> -> "<${command.name}>" + command is Command.Fallback<*> -> { + val name = command.name + val type = command.parser.type.name + "<$name> ($type)" + } else -> command.name } appendLine(" * $name") @@ -90,7 +87,10 @@ object DefaultHelpRenderer : HelpRenderer { private fun StringBuilder.arguments(arguments: List>) { appendLine("Arguments:") for (argument in arguments) { - appendLine(" * ${argument.name}") + val name = argument.name + val type = argument.parser.type.name + val range = renderRangeFull(argument.min, argument.max) + appendLine(" * $name ($type, $range)") } } @@ -98,7 +98,9 @@ object DefaultHelpRenderer : HelpRenderer { appendLine("Options:") for (option in options) { val name = option.names.joinToString(" | ") { it.withPrefix() } - appendLine(" * $name") + val type = option.parser.type.name + val range = renderRangeFull(option.min, option.max) + appendLine(" * $name ($type, $range)") } } @@ -106,4 +108,23 @@ object DefaultHelpRenderer : HelpRenderer { length == 1 -> "-$this" else -> "--$this" } + + private val KClass<*>.name: String + get() = simpleName ?: "Unknown" + + private fun renderRange(min: Int, max: Int?) = when { + max == null -> "{$min,}" + min == max -> "{$min}" + else -> "{$min,$max}" + } + + private fun renderRangeFull(min: Int, max: Int?) = when { + min == 1 && max == 1 -> "required" + min == 0 && max == 1 -> "optional" + else -> when { + max == null -> "at least $min" + min == max -> "exactly $min" + else -> "between $min and $max" + } + } } From c8e390266189f0fe2ded666997ef52b8e992359f Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 21:11:30 +0100 Subject: [PATCH 16/20] fix README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 48774e0..f0b7460 100644 --- a/README.md +++ b/README.md @@ -64,5 +64,5 @@ fun main(args: Array) = command("application") { println("""command "$value" executed!""") } } -}.run(args) +}.run(*args) ``` From fa5fa89bec4e6eac9502b4dfb4b0e84ffcaf4b59 Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 21:29:32 +0100 Subject: [PATCH 17/20] rename RunError to RuntimeFailure --- .../kotlin/com/infendro/cli/command/Command.kt | 2 +- .../com/infendro/cli/command/context/ContextParser.kt | 10 +++++----- .../com/infendro/cli/error/run/InvalidArgumentCount.kt | 2 +- .../com/infendro/cli/error/run/InvalidOptionCount.kt | 2 +- .../kotlin/com/infendro/cli/error/run/InvalidValue.kt | 2 +- .../com/infendro/cli/error/run/MalformedOption.kt | 2 +- .../com/infendro/cli/error/run/MissingOptionValue.kt | 2 +- .../cli/error/run/{RunError.kt => RuntimeFailure.kt} | 2 +- .../com/infendro/cli/error/run/UnexpectedArgument.kt | 2 +- .../com/infendro/cli/error/run/UnknownCommand.kt | 2 +- .../kotlin/com/infendro/cli/error/run/UnknownOption.kt | 2 +- .../kotlin/com/infendro/cli/parser/Parser.kt | 4 ++-- 12 files changed, 17 insertions(+), 17 deletions(-) rename src/commonMain/kotlin/com/infendro/cli/error/run/{RunError.kt => RuntimeFailure.kt} (68%) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 0faa25e..8d09800 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -31,7 +31,7 @@ sealed class Command private constructor( command.renderer.render(result.path)?.let { print(it) } } is ContextParser.Result.Failure -> { - println("error: ${result.error.message}") + println("error: ${result.failure.message}") val command = result.path.last() command.renderer.render(result.path)?.let { println() diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index 04d4e5c..f534d6f 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -19,13 +19,13 @@ internal class ContextParser( data class Failure( val path: List, - val error: RunError, + val failure: RuntimeFailure, ) : Result() } private fun success(context: Context) = Result.Success(context) private fun help(path: List) = Result.Help(path) - private fun failure(path: List, exception: RunError) = Result.Failure(path, exception) + private fun failure(path: List, exception: RuntimeFailure) = Result.Failure(path, exception) private var index = 0 private val current: String @@ -63,7 +63,7 @@ internal class ContextParser( 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(path, result.error) + is Parser.Result.Failure<*> -> return failure(path, result.failure) } commands[cmd.key] = value } @@ -91,7 +91,7 @@ internal class ContextParser( val value = when (val result = argument.parser.parse(trimmed)) { is Parser.Result.Success<*> -> result.value - is Parser.Result.Failure<*> -> return failure(path, result.error) + is Parser.Result.Failure<*> -> return failure(path, result.failure) } arguments[argument]!! += value argumentCount++ @@ -141,7 +141,7 @@ internal class ContextParser( text != null -> when (val result = option.parser.parse(current)) { is Parser.Result.Success<*> -> result.value - is Parser.Result.Failure<*> -> return failure(path, result.error) + is Parser.Result.Failure<*> -> return failure(path, result.failure) } option.flag -> option.fallback!! else -> return failure(path, MissingOptionValue(option)) diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt index eee6b47..a03a585 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt @@ -5,7 +5,7 @@ import com.infendro.cli.command.argument.Argument class InvalidArgumentCount( val argument: Argument<*>, val count: Int, -) : RunError() { +) : RuntimeFailure() { override val message: String get() { val expected = when { diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt index 7d85c92..49075af 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt @@ -5,7 +5,7 @@ import com.infendro.cli.command.option.Option class InvalidOptionCount( val option: Option<*>, val count: Int, -) : RunError() { +) : RuntimeFailure() { override val message: String get() { val expected = when { diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt index 957308d..dfc2562 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt @@ -5,7 +5,7 @@ import kotlin.reflect.KClass class InvalidValue( val value: String, val type: KClass<*>, -) : RunError() { +) : RuntimeFailure() { override val message: String get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt index bf3f09e..a79299a 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt @@ -2,7 +2,7 @@ package com.infendro.cli.error.run class MalformedOption( val option: String, -) : RunError() { +) : RuntimeFailure() { override val message: String get() = """malformed option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt index 3e66f4b..030b42d 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt @@ -4,7 +4,7 @@ import com.infendro.cli.command.option.Option class MissingOptionValue( val option: Option<*>, -) : RunError() { +) : RuntimeFailure() { override val message: String get() = """no value provided for option "${option.name}"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt similarity index 68% rename from src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt rename to src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt index b273109..a027c36 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/RunError.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt @@ -1,5 +1,5 @@ package com.infendro.cli.error.run -abstract class RunError { +abstract class RuntimeFailure { abstract val message: String } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt index ab04ef7..121ae15 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt @@ -1,6 +1,6 @@ package com.infendro.cli.error.run -class UnexpectedArgument : RunError() { +class UnexpectedArgument : RuntimeFailure() { override val message: String get() = """unexpected argument""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt index 69903c4..e8f2730 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt @@ -2,7 +2,7 @@ package com.infendro.cli.error.run class UnknownCommand( val command: String, -) : RunError() { +) : RuntimeFailure() { override val message: String get() = """unknown command "$command"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt index 1ccee98..da3e258 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt @@ -2,7 +2,7 @@ package com.infendro.cli.error.run class UnknownOption( val option: String, -) : RunError() { +) : RuntimeFailure() { override val message: String get() = """unknown option "$option"""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt index 4541b0d..257f2f0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -1,7 +1,7 @@ package com.infendro.cli.parser import com.infendro.cli.error.run.InvalidValue -import com.infendro.cli.error.run.RunError +import com.infendro.cli.error.run.RuntimeFailure import kotlin.reflect.KClass abstract class Parser( @@ -13,7 +13,7 @@ abstract class Parser( ) : Result() data class Failure( - val error: RunError, + val failure: RuntimeFailure, ) : Result() } From 47834c91d8ef716688d3518103038eee22a4aeab Mon Sep 17 00:00:00 2001 From: Infendro Date: Thu, 8 Jan 2026 22:21:41 +0100 Subject: [PATCH 18/20] fix option parsing --- .../com/infendro/cli/command/context/ContextParser.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index f534d6f..b652c13 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -31,7 +31,7 @@ internal class ContextParser( private val current: String get() = args[index] private val next: String? - get() = args.getOrNull(index) + get() = args.getOrNull(index + 1) private fun hasNext() = index < args.size private fun consume() { @@ -121,7 +121,7 @@ internal class ContextParser( else -> { if (next?.startsWith('-') == false) { consume() - trimmed to next + trimmed to current } else { trimmed to null } @@ -139,7 +139,7 @@ internal class ContextParser( val value = when { text != null -> - when (val result = option.parser.parse(current)) { + when (val result = option.parser.parse(text)) { is Parser.Result.Success<*> -> result.value is Parser.Result.Failure<*> -> return failure(path, result.failure) } From ff87ae90f5f1f4a5c5827d98144a3a29c3bcc1f6 Mon Sep 17 00:00:00 2001 From: Infendro Date: Fri, 9 Jan 2026 20:13:19 +0100 Subject: [PATCH 19/20] 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) From 562b9eb522a455f7ccc9909b31adf22149a31ff9 Mon Sep 17 00:00:00 2001 From: Infendro Date: Fri, 9 Jan 2026 23:28:02 +0100 Subject: [PATCH 20/20] implement suggestions for unknown command/option --- .../cli/command/context/ContextParser.kt | 8 ++-- .../cli/error/run/InvalidArgumentCount.kt | 2 +- .../cli/error/run/InvalidOptionCount.kt | 2 +- .../infendro/cli/error/run/InvalidValue.kt | 2 +- .../infendro/cli/error/run/MalformedOption.kt | 2 +- .../cli/error/run/MissingOptionValue.kt | 2 +- .../cli/error/run/UnexpectedArgument.kt | 6 ++- .../infendro/cli/error/run/UnknownCommand.kt | 20 +++++++- .../infendro/cli/error/run/UnknownOption.kt | 20 +++++++- .../kotlin/com/infendro/cli/util/Distance.kt | 48 +++++++++++++++++++ 10 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 src/commonMain/kotlin/com/infendro/cli/util/Distance.kt diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt index b652c13..9cdde49 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -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 -> diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt index a03a585..6ac72f7 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt @@ -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.""" } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt index 49075af..848cdb8 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt @@ -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.""" } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt index dfc2562..ecf5d99 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt @@ -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"}.""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt index a79299a..097db35 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt @@ -4,5 +4,5 @@ class MalformedOption( val option: String, ) : RuntimeFailure() { override val message: String - get() = """malformed option "$option"""" + get() = """Malformed option "$option".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt index 030b42d..49b1013 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt @@ -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}".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt index 121ae15..960d2d2 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt @@ -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".""" } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt index e8f2730..643c51c 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt @@ -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"?""") + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt index da3e258..cb01a69 100644 --- a/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt @@ -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"?""") + } } diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt b/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt new file mode 100644 index 0000000..b11ca6b --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/util/Distance.kt @@ -0,0 +1,48 @@ +package com.infendro.cli.util + +internal const val threshold = 2 + +/** + * Damerau-Levenshtein 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] +}