diff --git a/README.md b/README.md index fdcbe38..67ec8e5 100644 --- a/README.md +++ b/README.md @@ -21,46 +21,48 @@ 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) = command("application", description = "example application") { + execute { + // define execution of command (e.g., display help) + help() + } + + // register command "greet" + +command("greet", description = "greet someone") { + // declare and register arguments + val speakersArg = +argument.string("speakers", description = "the greeters").variable(min = 1) + + // declare and register options + val greetingOpt = +option.string("greeting", "greet", "g", description = "the greeting").orElse("Hello") + val nameOpt = +option.string("name", "n", description = "the greetee").orNull() + 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") + // register more commands... + } - execute { - // retrieve the arguments - val greeting by greetingArg - val name by nameArg + // register fallback command "value" + +command.fallback.string("value", description = "execute an arbitrary command") { valueArg -> + execute { + // retrieve the value used for the fallback command + val value by valueArg - 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/Dsl.kt b/src/commonMain/kotlin/com/infendro/cli/Dsl.kt new file mode 100644 index 0000000..084718c --- /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/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/Command.kt b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt index 550bf6d..da59ca0 100644 --- a/src/commonMain/kotlin/com/infendro/cli/command/Command.kt +++ b/src/commonMain/kotlin/com/infendro/cli/command/Command.kt @@ -1,102 +1,184 @@ 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.Dsl +import com.infendro.cli.command.argument.Argument +import com.infendro.cli.command.context.Context +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.error.build.* +import com.infendro.cli.parser.Parser +import com.infendro.cli.util.Regex -class Command private constructor( - val name: String?, +sealed class Command private constructor( + val name: String, + val description: String?, val commands: List, val arguments: List>, - private val block: Execution.() -> Unit, + val options: List>, + internal val execute: Context.() -> Unit, + internal val renderer: HelpRenderer, ) { - 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 nextIndex: Int - get() = index + 1 - - fun command(name: String? = null, block: Builder.(Key) -> Unit) { - if (name != null && !name.matches(COMMAND_NAME)) - throw IllegalCommandNameException(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) - } - - 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) + 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() + 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() + val help = command.renderer.render(result.path) + if (help != null) print("\n$help") } - - arguments.add(argument) - } - - fun arguments(vararg arguments: Argument<*>) = - arguments.forEach(::argument) - - fun execute(block: Execution.() -> Unit) { - if (this::block.isInitialized) - throw DuplicateExecuteException() - - this.block = block - } - - fun build(): Command { - if (!this::block.isInitialized) - throw NoExecuteException() - - return Command(name, commands, arguments, block) } } - class Key internal constructor( - internal val index: Int, - ) -} + class Named( + name: String, + description: String?, + commands: List, + arguments: List>, + options: List>, + execute: Context.() -> Unit, + renderer: HelpRenderer, + ) : Command(name, description, commands, arguments, options, execute, renderer) { + class Builder( + name: String, + description: String?, + ) : Command.Builder(name, description) { + override fun create() = + Named(name, description, commands, arguments, options, execute, renderer) + } + } -fun cli(block: Command.Builder.() -> Unit): Command { - val builder = Command.Builder(-1, null) - builder.block() - return builder.build() + class Fallback( + val key: Key, + val parser: Parser, + name: String, + description: String?, + commands: List, + arguments: List>, + options: List>, + execute: Context.() -> Unit, + renderer: HelpRenderer, + ) : Command(name, description, commands, arguments, options, execute, renderer) { + class Builder( + val parser: Parser, + name: String, + description: String?, + ) : Command.Builder>(name, description) { + internal val key = Key() + + override fun create() = + Fallback(key, parser, name, description, commands, arguments, options, execute, renderer) + } + + class Key + } + + @Dsl + sealed class Builder( + protected val name: String, + protected val description: String?, + ) { + protected val commands = mutableListOf() + protected val arguments = mutableListOf>() + protected val options = mutableListOf>() + private var _execute: (Context.() -> Unit)? = null + private var _renderer: HelpRenderer? = null + + protected val execute: Context.() -> Unit + get() = _execute!! + protected val renderer: HelpRenderer + get() = _renderer!! + + fun register(command: Command) { + validateCommand(command) + commands += command + } + + fun register(vararg commands: Command) = commands.forEach(::register) + operator fun T.unaryPlus() = also(::register) + + fun register(argument: Argument<*>) { + validateArgument(argument) + arguments += argument + } + + fun register(vararg arguments: Argument<*>) = arguments.forEach(::register) + operator fun > T.unaryPlus() = also(::register) + + fun register(option: Option<*>) { + validateOption(option) + options += option + } + + fun register(vararg options: Option<*>) = options.forEach(::register) + operator fun > T.unaryPlus() = also(::register) + + fun execute(block: Context.() -> Unit) { + validateExecute() + _execute = block + } + + fun renderer(renderer: HelpRenderer) { + validateRenderer() + _renderer = renderer + } + + internal abstract fun create(): COMMAND + + internal fun build(): Command { + if (_execute == null) _execute = {} + if (_renderer == null) _renderer = DefaultHelpRenderer + + validate() + return create() + } + + 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) + } + + private fun validateArgument(argument: Argument<*>) { + if (arguments.isNotEmpty()) { + val last = arguments.last() + when { + last.unbounded -> throw InvalidArgumentOrder() + argument.required && last.optional -> throw InvalidArgumentOrder() + } + } + 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(option) + } + } + + private fun validateExecute() { + if (_execute != null) throw DuplicateExecute() + } + + private fun validateRenderer() { + if (_renderer != null) throw DuplicateRenderer() + } + + private fun validate() { + if (commands.any { it is Fallback<*> } && arguments.isNotEmpty()) throw InvalidFallback() + } + } } 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..1236270 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/CommandFactory.kt @@ -0,0 +1,50 @@ +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, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(StringParser, name, description, block) + + fun int(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(IntParser, name, description, block) + + fun long(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(LongParser, name, description, block) + + fun float(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(FloatParser, name, description, block) + + fun double(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(DoubleParser, name, description, block) + + fun boolean(name: String, description: String? = null, block: Builder<*>.(Fallback.Key) -> Unit) = + fallback(BooleanParser, name, description, 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, + 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, + 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/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/argument/Argument.kt b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt new file mode 100644 index 0000000..542e721 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/Argument.kt @@ -0,0 +1,70 @@ +package com.infendro.cli.command.argument + +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 + +sealed class Argument( + val parser: Parser, + val name: String, + val description: String?, + val min: Int, + val max: Int?, +) { + init { + when { + !name.matches(ARGUMENT) -> throw InvalidArgumentName(name) + min !in 0..count || max == 0 -> 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, + name: String, + 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, description, min = 0, max = 1) + + class OrNull( + parser: Parser, + name: String, + 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, 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 new file mode 100644 index 0000000..38fcf6e --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/argument/ArgumentFactory.kt @@ -0,0 +1,31 @@ +package com.infendro.cli.command.argument + +import com.infendro.cli.parser.* + +object ArgumentFactory { + fun string(name: String, description: String? = null) = + argument(StringParser, name, description) + + 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, description: String? = null) = + Argument.Required(parser, name, description) 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..9f1c866 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/Context.kt @@ -0,0 +1,63 @@ +package com.infendro.cli.command.context + +import com.infendro.cli.Dsl +import com.infendro.cli.command.Command +import com.infendro.cli.command.argument.Argument +import com.infendro.cli.command.option.Option +import kotlin.reflect.KProperty + +@Dsl +class Context internal constructor( + 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.renderer.render(path)?.let { print(it) } + } + + @Suppress("UNCHECKED_CAST") + private val Command.Fallback.Key.value: T + get() = commands[this] as T + + operator fun Command.Fallback.Key.getValue(thisRef: Any?, property: KProperty<*>): T = + value + + @Suppress("UNCHECKED_CAST") + 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 Option.values: List + get() = options[this] as List + + operator fun Option.Required.getValue(thisRef: Any?, property: KProperty<*>): T = + values.first() + + operator fun Option.OrElse.getValue(thisRef: Any?, property: KProperty<*>): T = + values.firstOrNull() ?: other + + operator fun Option.OrNull.getValue(thisRef: Any?, property: KProperty<*>): T? = + values.firstOrNull() + + operator fun Option.Variable.getValue(thisRef: Any?, property: KProperty<*>): List = + values +} diff --git a/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt new file mode 100644 index 0000000..9cdde49 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/context/ContextParser.kt @@ -0,0 +1,183 @@ +package com.infendro.cli.command.context + +import com.infendro.cli.command.Command +import com.infendro.cli.error.run.* +import com.infendro.cli.parser.Parser + +internal class ContextParser( + private val root: Command, + private val args: Array, +) { + sealed class Result { + data class Success( + val context: Context, + ) : Result() + + data class Help( + val path: List, + ) : Result() + + data class Failure( + val path: List, + 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: RuntimeFailure) = Result.Failure(path, exception) + + private var index = 0 + private val current: String + get() = args[index] + private val next: String? + get() = args.getOrNull(index + 1) + + private fun hasNext() = index < args.size + private fun consume() { + index++ + } + + fun parse(): Result { + // command + var command = root + val path = mutableListOf(root) + val commands = mutableMapOf, Any>() + + while (hasNext()) { + if (current.startsWith("-")) + break + + val cmd = command.commands.firstOrNull { + (it is Command.Named && it.name == current) || it is Command.Fallback<*> + } + if (cmd == null) { + when { + command.arguments.isEmpty() -> return failure(path, UnknownCommand(command, 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(path, result.failure) + } + 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 + + while (hasNext()) { + val dashes = current.takeWhile { it == '-' }.count() + val trimmed = current.drop(dashes) + + when { + // argument + endOfOptions || dashes == 0 -> { + val argument = command.arguments.getOrNull(argumentIndex) + ?: return failure(path, UnexpectedArgument(current)) + + val value = when (val result = argument.parser.parse(current)) { + is Parser.Result.Success<*> -> result.value + is Parser.Result.Failure<*> -> return failure(path, result.failure) + } + arguments[argument]!! += value + argumentCount++ + + if (argumentCount == argument.count) { + argumentCount = 0 + argumentIndex++ + } + } + + // option + dashes in 1..2 -> { + if (dashes == 2 && trimmed.isEmpty()) { + endOfOptions = true + consume() + continue + } + + if (dashes == 2 && trimmed == "help") + return help(path) + + val (name, text) = when { + trimmed.contains('=') -> { + val (name, value) = trimmed.split('=', limit = 2) + name to value + } + else -> { + if (next?.startsWith('-') == false) { + consume() + trimmed to current + } else { + trimmed to null + } + } + } + + val names = when { + dashes == 1 -> name.map { "$it" } + else -> listOf(name) + } + + for (name in names) { + val option = command.options.firstOrNull { name in it.names } + ?: return failure(path, UnknownOption(command, name)) + + val value = when { + text != null -> + when (val result = option.parser.parse(text)) { + is Parser.Result.Success<*> -> result.value + is Parser.Result.Failure<*> -> return failure(path, result.failure) + } + option.flag -> option.fallback!! + else -> return failure(path, MissingOptionValue(option)) + } + options[option]!! += value + } + } + + // malformed option + else -> { + when { + current.contains('=') -> { + val (option) = current.split('=', limit = 2) + return failure(path, MalformedOption(option)) + } + else -> return failure(path, MalformedOption(current)) + } + } + } + + consume() + } + + // validate arguments + for (argument in command.arguments) { + val count = arguments[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(path, InvalidOptionCount(option, count)) + } + + 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 new file mode 100644 index 0000000..79febeb --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/help/DefaultHelpRenderer.kt @@ -0,0 +1,189 @@ +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 { + val command = path.last() + + usage(path) + if (command.description != null) { + appendLine() + appendLine(command.description) + } + 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(path: List) { + val command = path.last() + + append("Usage:") + for (command in path) { + append(" ") + when { + command is 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 = renderRange(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 = renderRange(option.min, option.max) + append("($text)$range") + } + } + } + appendLine() + } + + private fun StringBuilder.commands(commands: List) { + val rows = commands.map { command -> + val name = when { + command is Command.Fallback<*> -> "<${command.name}>" + else -> command.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>) { + val rows = arguments.map { argument -> + val name = argument.name + val type = argument.parser.type.name + 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>) { + val rows = options.map { option -> + val name = option.names.joinToString(" | ") { it.withPrefix() } + val type = option.parser.type.name + 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 ?: ""}") + } + } + + private fun String.withPrefix() = when { + 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" + } + } + + 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/HelpRenderer.kt b/src/commonMain/kotlin/com/infendro/cli/command/help/HelpRenderer.kt new file mode 100644 index 0000000..d923c5b --- /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(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 new file mode 100644 index 0000000..677865b --- /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(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 new file mode 100644 index 0000000..8b2aca0 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/Option.kt @@ -0,0 +1,83 @@ +package com.infendro.cli.command.option + +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 +import com.infendro.cli.util.Regex.OPTION + +sealed class Option( + val parser: Parser, + val fallback: T?, + val names: List, + val description: String?, + val min: Int, + val max: Int?, +) { + val name: String + get() = names.maxBy { it.length } + + val flag: Boolean + get() = fallback != null + + 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 + + init { + when { + names.isEmpty() -> throw MissingOptionName() + names.any { !it.matches(OPTION) } -> throw InvalidOptionName(name) + min !in 0..count || max == 0 -> throw InvalidRange() + } + } + + class Required( + parser: Parser, + fallback: T?, + names: List, + description: String?, + ) : Option(parser, fallback, names, description, min = 1, max = 1) { + fun orElse(other: T) = + OrElse(parser, fallback, names, description, other) + + 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, description, min = 0, max = 1) + + class OrNull( + parser: Parser, + fallback: T?, + names: List, + 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, 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 new file mode 100644 index 0000000..fea7dd1 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/command/option/OptionFactory.kt @@ -0,0 +1,34 @@ +package com.infendro.cli.command.option + +import com.infendro.cli.parser.* + +object OptionFactory { + fun string(vararg names: String, description: String? = null) = + option(StringParser, *names, description = description) + + 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, description: String? = null) = + Option.Required(parser, fallback = null, names.asList(), description) + +fun option(parser: Parser, fallback: T, vararg names: String, description: String? = null) = + Option.Required(parser, fallback, names.asList(), description) 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..0c1af10 --- /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 : 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 new file mode 100644 index 0000000..5f3bbab --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateArgument.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.error.build + +import com.infendro.cli.command.argument.Argument + +class DuplicateArgument( + val argument: Argument<*>, +) : BuildException() { + override val message: String + 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 new file mode 100644 index 0000000..4919bb0 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateCommand.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.error.build + +import com.infendro.cli.command.Command + +class DuplicateCommand( + val command: Command, +) : BuildException() { + override val message: String + get() = """duplicate command "${command.name}"""" +} 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..52f3c3b --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/DuplicateOption.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.error.build + +import com.infendro.cli.command.option.Option + +class DuplicateOption( + val option: Option<*>, +) : BuildException() { + override val message: String + get() = """duplicate option "${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/InvalidArgumentName.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentName.kt new file mode 100644 index 0000000..8a9a3d5 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidArgumentName.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidArgumentName( + 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/InvalidCommandName.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandName.kt new file mode 100644 index 0000000..e1fd029 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidCommandName.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidCommandName( + 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/InvalidOptionName.kt b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOptionName.kt new file mode 100644 index 0000000..1eeaa0d --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/build/InvalidOptionName.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.build + +class InvalidOptionName( + 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/error/run/InvalidArgumentCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt new file mode 100644 index 0000000..6ac72f7 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidArgumentCount.kt @@ -0,0 +1,18 @@ +package com.infendro.cli.error.run + +import com.infendro.cli.command.argument.Argument + +class InvalidArgumentCount( + val argument: Argument<*>, + val count: Int, +) : RuntimeFailure() { + 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/error/run/InvalidOptionCount.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt new file mode 100644 index 0000000..848cdb8 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidOptionCount.kt @@ -0,0 +1,18 @@ +package com.infendro.cli.error.run + +import com.infendro.cli.command.option.Option + +class InvalidOptionCount( + val option: Option<*>, + val count: Int, +) : RuntimeFailure() { + 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/error/run/InvalidValue.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt new file mode 100644 index 0000000..ecf5d99 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/InvalidValue.kt @@ -0,0 +1,11 @@ +package com.infendro.cli.error.run + +import kotlin.reflect.KClass + +class InvalidValue( + val value: String, + val type: KClass<*>, +) : 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 new file mode 100644 index 0000000..097db35 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MalformedOption.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.run + +class MalformedOption( + val option: String, +) : 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 new file mode 100644 index 0000000..49b1013 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/MissingOptionValue.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.error.run + +import com.infendro.cli.command.option.Option + +class MissingOptionValue( + val option: Option<*>, +) : RuntimeFailure() { + override val message: String + get() = """No value provided for option "${option.name}".""" +} diff --git a/src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt b/src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt new file mode 100644 index 0000000..a027c36 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/RuntimeFailure.kt @@ -0,0 +1,5 @@ +package com.infendro.cli.error.run + +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 new file mode 100644 index 0000000..960d2d2 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnexpectedArgument.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.error.run + +class UnexpectedArgument( + val argument: String, +) : RuntimeFailure() { + override val message: String + 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 new file mode 100644 index 0000000..643c51c --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownCommand.kt @@ -0,0 +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: Command, + val name: String, +) : RuntimeFailure() { + override val message: String + 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 new file mode 100644 index 0000000..cb01a69 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/error/run/UnknownOption.kt @@ -0,0 +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 command: Command, + val name: String, +) : RuntimeFailure() { + override val message: String + 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/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/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/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/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/IllegalArgumentNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalArgumentNameException.kt deleted file mode 100644 index 3ad2f2d..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalArgumentNameException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class IllegalArgumentNameException( - val name: String, -) : Exception() diff --git a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt deleted file mode 100644 index f857b99..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/IllegalCommandNameException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.infendro.cli.exception.build - -class IllegalCommandNameException( - val name: String, -) : 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/NoExecuteException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/build/NoExecuteException.kt deleted file mode 100644 index 4fc891b..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/build/NoExecuteException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.infendro.cli.exception.build - -class NoExecuteException : Exception() 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/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/MissingArgumentException.kt b/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt deleted file mode 100644 index 49fab78..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/MissingArgumentException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -class MissingArgumentException( - val argument: String, -) : CliException() { - override val message: String - get() = """missing argument "$argument"""" -} 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 b6966f0..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/ParseException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -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/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 deleted file mode 100644 index 795a923..0000000 --- a/src/commonMain/kotlin/com/infendro/cli/exception/run/UnknownCommandException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.infendro.cli.exception.run - -import com.infendro.cli.exception.CliException - -class UnknownCommandException( - val command: String, -) : CliException() { - override val message: String - get() = """unknown command "$command"""" -} 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..2389e45 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/BooleanParser.kt @@ -0,0 +1,10 @@ +package com.infendro.cli.parser + +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 new file mode 100644 index 0000000..2d1215b --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/DoubleParser.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.parser + +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 new file mode 100644 index 0000000..5ee0b74 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/EnumParser.kt @@ -0,0 +1,16 @@ +package com.infendro.cli.parser + +import kotlin.enums.enumEntries +import kotlin.reflect.KClass + +class EnumParser>( + type: KClass, + private val values: List, +) : 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 new file mode 100644 index 0000000..c41f620 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/FloatParser.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.parser + +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 new file mode 100644 index 0000000..3bf23ef --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/IntParser.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.parser + +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 new file mode 100644 index 0000000..4f218aa --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/LongParser.kt @@ -0,0 +1,8 @@ +package com.infendro.cli.parser + +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 new file mode 100644 index 0000000..257f2f0 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/Parser.kt @@ -0,0 +1,24 @@ +package com.infendro.cli.parser + +import com.infendro.cli.error.run.InvalidValue +import com.infendro.cli.error.run.RuntimeFailure +import kotlin.reflect.KClass + +abstract class Parser( + val type: KClass, +) { + sealed class Result { + data class Success( + val value: T, + ) : Result() + + data class Failure( + val failure: RuntimeFailure, + ) : Result() + } + + 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 new file mode 100644 index 0000000..ebf2f24 --- /dev/null +++ b/src/commonMain/kotlin/com/infendro/cli/parser/StringParser.kt @@ -0,0 +1,6 @@ +package com.infendro.cli.parser + +object StringParser : Parser(String::class) { + override fun parse(text: String) = + success(text) +} 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] +} diff --git a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt index b7cd051..cf613b4 100644 --- a/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt +++ b/src/commonMain/kotlin/com/infendro/cli/util/Regex.kt @@ -1,6 +1,7 @@ package com.infendro.cli.util internal object Regex { - val COMMAND_NAME = Regex("""[a-z]+(?:-[a-z]+)*""") - val ARGUMENT_NAME = Regex("""[a-z]+(?:[\-.][a-z]+)*""") + 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]+)*""") }