Compare commits

..

19 Commits

Author SHA1 Message Date
6f1134a6fb Merge pull request '1.4.1 release' (#10) from develop into main
All checks were successful
/ publish (push) Successful in 3m54s
Reviewed-on: Infendro/cli-kt#10
2026-01-29 16:48:33 +00:00
21769c20c4 enforce exception message 2026-01-18 21:45:55 +01:00
d5a66f6853 bump version to 1.4.1 2026-01-17 16:02:14 +01:00
f55869193e add additional targets 2026-01-17 16:00:36 +01:00
9ad2d2c5e0 rename ci 2026-01-17 15:59:07 +01:00
a5b578a47d split error and exception 2026-01-17 15:57:05 +01:00
dbd9d53050 improve ci 2026-01-13 15:39:50 +01:00
87e6e65809 improve string distance documentation 2026-01-10 15:12:36 +01:00
54972c36b6 refactor package structure 2026-01-10 09:32:55 +01:00
f90e960728 1.4.0 release
Some checks are pending
/ publish (push) Has started running
2026-01-10 07:37:21 +00:00
562b9eb522 implement suggestions for unknown command/option 2026-01-10 07:25:46 +01:00
ff87ae90f5 implement descriptions, improve default help 2026-01-09 20:19:59 +01:00
47834c91d8 fix option parsing 2026-01-08 22:21:41 +01:00
fa5fa89bec rename RunError to RuntimeFailure 2026-01-08 21:29:32 +01:00
c8e3902661 fix README 2026-01-08 21:11:30 +01:00
7001b00f5f improve help
display type for fallback commands
display type and range for arguments/options
2026-01-08 21:09:04 +01:00
f33caa30c0 improve exceptions 2026-01-08 19:42:48 +01:00
6aa6c1b3c8 improve command handling, change argument/option registration 2026-01-08 18:53:54 +01:00
2a9f086c9d improve exceptions 2026-01-07 22:11:37 +01:00
63 changed files with 726 additions and 453 deletions

View File

@@ -1,36 +0,0 @@
on:
push:
branches:
- main
env:
INFENDRO__TOKEN: ${{ secrets.TOKEN }}
jobs:
publish:
runs-on: linux
steps:
- uses: actions/checkout@v4
- uses: actions/cache/restore@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/
- uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- run: ./gradlew publish
- uses: actions/cache/save@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/

View File

@@ -0,0 +1,47 @@
on:
push:
branches: [ main ]
jobs:
publish:
runs-on: linux
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
key: gradle-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
- name: Cache Konan
uses: actions/cache@v4
with:
key: konan-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: konan
path: |
~/.konan/
- name: Cache Node
uses: actions/cache@v4
with:
key: node-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: node
path: |
~/.gradle/nodejs
~/.gradle/yarn
- name: Publish
run: ./gradlew publish
env:
INFENDRO__TOKEN: ${{ secrets.TOKEN }}

View File

@@ -2,15 +2,6 @@
`cli-kt` is a Kotlin Multiplatform framework for implementing terminal applications. `cli-kt` is a Kotlin Multiplatform framework for implementing terminal applications.
## Targets
| Target | Status |
|------------------|---------------|
| JVM | Supported |
| JavaScript | Supported |
| Native (Linux) | Supported |
| Native (Windows) | Not Supported |
## Installation ## Installation
Add the following to your `build.gradle.kts`. Add the following to your `build.gradle.kts`.
@@ -21,27 +12,27 @@ repositories {
} }
dependencies { dependencies {
implementation("com.infendro:cli:1.4.0") implementation("com.infendro:cli:1.4.1")
} }
``` ```
## Usage ## Usage
```kotlin ```kotlin
fun main(args: Array<String>) = cli("application") { fun main(args: Array<String>) = command("application", description = "example application") {
execute { execute {
// define execution of command (e.g., display help) // define execution of command (e.g., display help)
help() help()
} }
// declare command "greet" // register command "greet"
command("greet") { +command("greet", description = "greet someone") {
// declare arguments // declare and register arguments
val speakersArg by argument.string("speakers").variable(min = 1) val speakersArg = +argument.string("speakers", description = "the greeters").variable(min = 1)
// declare options // declare and register options
val greetingOpt by option.string("greeting", "greet", "g").orElse("Hello") val greetingOpt = +option.string("greeting", "greet", "g", description = "the greeting").orElse("Hello")
val nameOpt by option.string("name", "n").orNull() val nameOpt = +option.string("name", "n", description = "the greetee").orNull()
execute { execute {
// retrieve the arguments and options // retrieve the arguments and options
@@ -52,11 +43,11 @@ fun main(args: Array<String>) = cli("application") {
println("${speakers.joinToString()}: $greeting ${name ?: "World"}!") println("${speakers.joinToString()}: $greeting ${name ?: "World"}!")
} }
// declare more commands... // register more commands...
} }
// declare fallback command // register fallback command "value"
fallback.string("value") { valueArg -> +command.fallback.string("value", description = "execute an arbitrary command") { valueArg ->
execute { execute {
// retrieve the value used for the fallback command // retrieve the value used for the fallback command
val value by valueArg val value by valueArg
@@ -64,5 +55,5 @@ fun main(args: Array<String>) = cli("application") {
println("""command "$value" executed!""") println("""command "$value" executed!""")
} }
} }
}.run(args) }.run(*args)
``` ```

View File

@@ -1,8 +1,11 @@
@file:OptIn(ExperimentalWasmDsl::class)
import com.infendro.plugin.infendro import com.infendro.plugin.infendro
import com.infendro.plugin.token import com.infendro.plugin.token
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
group = "com.infendro" group = "com.infendro"
version = "1.4.0" version = "1.4.1"
plugins { plugins {
alias(libs.plugins.infendro) alias(libs.plugins.infendro)
@@ -12,10 +15,14 @@ plugins {
kotlin { kotlin {
jvm() jvm()
js {
nodejs()
}
linuxX64() linuxX64()
linuxArm64()
mingwX64()
macosX64()
macosArm64()
js { nodejs() }
wasmJs { nodejs() }
wasmWasi { nodejs() }
repositories { repositories {
mavenCentral() mavenCentral()

View File

@@ -1,6 +1,6 @@
[versions] [versions]
infendro = "1.0.0" infendro = "1.0.0"
kotlin = "2.2.21" kotlin = "2.3.0"
[plugins] [plugins]
infendro = { id = "com.infendro.plugin", version.ref = "infendro" } infendro = { id = "com.infendro.plugin", version.ref = "infendro" }

View File

@@ -0,0 +1,70 @@
package com.infendro.cli.argument
import com.infendro.cli.exception.InvalidArgumentName
import com.infendro.cli.exception.InvalidRange
import com.infendro.cli.parser.Parser
import com.infendro.cli.util.Regex.ARGUMENT
sealed class Argument<T : Any>(
val parser: Parser<T>,
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<T : Any>(
parser: Parser<T>,
name: String,
description: String?,
) : Argument<T>(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<T : Any>(
parser: Parser<T>,
name: String,
description: String?,
val other: T,
) : Argument<T>(parser, name, description, min = 0, max = 1)
class OrNull<T : Any>(
parser: Parser<T>,
name: String,
description: String?,
) : Argument<T>(parser, name, description, min = 0, max = 1)
class Variable<T : Any>(
parser: Parser<T>,
name: String,
description: String?,
min: Int,
max: Int?,
) : Argument<T>(parser, name, description, min, max)
}

View File

@@ -0,0 +1,31 @@
package com.infendro.cli.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 <reified T : Enum<T>> enum(name: String, description: String? = null) =
argument(enumParser<T>(), name, description)
}
val argument = ArgumentFactory
fun <T : Any> argument(parser: Parser<T>, name: String, description: String? = null) =
Argument.Required(parser, name, description)

View File

@@ -1,87 +1,84 @@
package com.infendro.cli.command package com.infendro.cli.command
import com.infendro.cli.Dsl import com.infendro.cli.Dsl
import com.infendro.cli.command.argument.Argument import com.infendro.cli.argument.Argument
import com.infendro.cli.command.context.Context import com.infendro.cli.command.context.Context
import com.infendro.cli.command.context.ContextParser import com.infendro.cli.command.context.ContextParser
import com.infendro.cli.command.help.DefaultHelpRenderer import com.infendro.cli.command.help.DefaultHelpRenderer
import com.infendro.cli.command.help.HelpRenderer import com.infendro.cli.command.help.HelpRenderer
import com.infendro.cli.command.option.Option import com.infendro.cli.exception.*
import com.infendro.cli.exception.build.* import com.infendro.cli.option.Option
import com.infendro.cli.parser.* import com.infendro.cli.parser.Parser
import com.infendro.cli.util.Regex 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( sealed class Command private constructor(
val parent: Command?,
val name: String, val name: String,
val description: String?,
val commands: List<Command>, val commands: List<Command>,
val arguments: List<Argument<*>>, val arguments: List<Argument<*>>,
val options: List<Option<*>>, val options: List<Option<*>>,
internal val execute: Context.() -> Unit, internal val execute: Context.() -> Unit,
internal val renderer: HelpRenderer, internal val renderer: HelpRenderer,
) { ) {
val path: List<Command> init {
get() = when { if (!name.matches(Regex.COMMAND)) throw InvalidCommandName(name)
parent == null -> listOf(this) }
else -> parent.path + this
}
val help: String? fun run(vararg args: String) {
get() = renderer.render(this) when (val result = ContextParser(this, arrayOf(*args)).parse()) {
fun run(args: Array<String>) {
when (val result = ContextParser(this, args).parse()) {
is ContextParser.Result.Success -> result.context.execute() 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()
val help = command.renderer.render(result.path)
if (help != null) print(help)
}
is ContextParser.Result.Failure -> { is ContextParser.Result.Failure -> {
println("error: ${result.error.message}") println("error: ${result.error.message}")
result.command.help?.let { val command = result.path.last()
println() val help = command.renderer.render(result.path)
print(it) if (help != null) print("\n$help")
}
} }
} }
} }
class Named( class Named(
parent: Command?,
name: String, name: String,
description: String?,
commands: List<Command>, commands: List<Command>,
arguments: List<Argument<*>>, arguments: List<Argument<*>>,
options: List<Option<*>>, options: List<Option<*>>,
execute: Context.() -> Unit, execute: Context.() -> Unit,
renderer: HelpRenderer, renderer: HelpRenderer,
) : Command(parent, name, commands, arguments, options, execute, renderer) { ) : Command(name, description, commands, arguments, options, execute, renderer) {
class Builder( class Builder(
name: String, name: String,
) : Command.Builder<Named>(name) { description: String?,
) : Command.Builder<Named>(name, description) {
override fun create() = override fun create() =
Named(parent, name, commands, arguments, options, execute, renderer) Named(name, description, commands, arguments, options, execute, renderer)
} }
} }
class Fallback<T : Any>( class Fallback<T : Any>(
parent: Command?,
val key: Key<T>, val key: Key<T>,
val parser: Parser<T>, val parser: Parser<T>,
name: String, name: String,
description: String?,
commands: List<Command>, commands: List<Command>,
arguments: List<Argument<*>>, arguments: List<Argument<*>>,
options: List<Option<*>>, options: List<Option<*>>,
execute: Context.() -> Unit, execute: Context.() -> Unit,
renderer: HelpRenderer, renderer: HelpRenderer,
) : Command(parent, name, commands, arguments, options, execute, renderer) { ) : Command(name, description, commands, arguments, options, execute, renderer) {
class Builder<T : Any>( class Builder<T : Any>(
val parser: Parser<T>, val parser: Parser<T>,
name: String, name: String,
) : Command.Builder<Fallback<T>>(name) { description: String?,
) : Command.Builder<Fallback<T>>(name, description) {
internal val key = Key<T>() internal val key = Key<T>()
override fun create() = override fun create() =
Fallback(parent, key, parser, name, commands, arguments, options, execute, renderer) Fallback(key, parser, name, description, commands, arguments, options, execute, renderer)
} }
class Key<T> class Key<T>
@@ -90,9 +87,8 @@ sealed class Command private constructor(
@Dsl @Dsl
sealed class Builder<COMMAND : Command>( sealed class Builder<COMMAND : Command>(
protected val name: String, protected val name: String,
protected val description: String?,
) { ) {
protected var parent: Command? = null
private val builders = mutableListOf<Builder<*>>()
protected val commands = mutableListOf<Command>() protected val commands = mutableListOf<Command>()
protected val arguments = mutableListOf<Argument<*>>() protected val arguments = mutableListOf<Argument<*>>()
protected val options = mutableListOf<Option<*>>() protected val options = mutableListOf<Option<*>>()
@@ -104,40 +100,13 @@ sealed class Command private constructor(
protected val renderer: HelpRenderer protected val renderer: HelpRenderer
get() = _renderer!! get() = _renderer!!
fun command(name: String, block: Builder<*>.() -> Unit) { fun register(command: Command) {
validateCommand(name) validateCommand(command)
builders += Named.Builder(name).apply(block) commands += command
} }
inner class FallbackFactory { fun register(vararg commands: Command) = commands.forEach(::register)
fun string(name: String, block: Builder<*>.(Fallback.Key<String>) -> Unit) = operator fun <T : Command> T.unaryPlus() = also(::register)
fallback(StringParser, name, block)
fun int(name: String, block: Builder<*>.(Fallback.Key<Int>) -> Unit) =
fallback(IntParser, name, block)
fun long(name: String, block: Builder<*>.(Fallback.Key<Long>) -> Unit) =
fallback(LongParser, name, block)
fun float(name: String, block: Builder<*>.(Fallback.Key<Float>) -> Unit) =
fallback(FloatParser, name, block)
fun double(name: String, block: Builder<*>.(Fallback.Key<Double>) -> Unit) =
fallback(DoubleParser, name, block)
fun boolean(name: String, block: Builder<*>.(Fallback.Key<Boolean>) -> Unit) =
fallback(BooleanParser, name, block)
inline fun <reified T : Enum<T>> enum(name: String, noinline block: Builder<*>.(Fallback.Key<T>) -> Unit) =
fallback(enumParser<T>(), name, block)
}
val fallback = FallbackFactory()
fun <T : Any> fallback(parser: Parser<T>, name: String, block: Fallback.Builder<T>.(Fallback.Key<T>) -> Unit) {
validateCommand(name)
builders += Fallback.Builder(parser, name).apply { block(key) }
}
fun register(argument: Argument<*>) { fun register(argument: Argument<*>) {
validateArgument(argument) validateArgument(argument)
@@ -145,9 +114,7 @@ sealed class Command private constructor(
} }
fun register(vararg arguments: Argument<*>) = arguments.forEach(::register) fun register(vararg arguments: Argument<*>) = arguments.forEach(::register)
operator fun <T : Argument<*>> T.unaryPlus() = also(::register)
operator fun <T : Argument<*>> T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register)
operator fun <T : Argument<*>> T.getValue(thisRef: Nothing?, property: KProperty<*>) = this
fun register(option: Option<*>) { fun register(option: Option<*>) {
validateOption(option) validateOption(option)
@@ -155,9 +122,7 @@ sealed class Command private constructor(
} }
fun register(vararg options: Option<*>) = options.forEach(::register) fun register(vararg options: Option<*>) = options.forEach(::register)
operator fun <T : Option<*>> T.unaryPlus() = also(::register)
operator fun <T : Option<*>> T.provideDelegate(thisRef: Nothing?, property: KProperty<*>) = also(::register)
operator fun <T : Option<*>> T.getValue(thisRef: Nothing?, property: KProperty<*>) = this
fun execute(block: Context.() -> Unit) { fun execute(block: Context.() -> Unit) {
validateExecute() validateExecute()
@@ -169,32 +134,22 @@ sealed class Command private constructor(
_renderer = renderer _renderer = renderer
} }
internal abstract fun create(): COMMAND
internal fun build(): Command { internal fun build(): Command {
if (_execute == null) _execute = {} if (_execute == null) _execute = {}
if (_renderer == null) _renderer = DefaultHelpRenderer if (_renderer == null) _renderer = DefaultHelpRenderer
validate() validate()
val command = create() return create()
for (builder in builders) {
builder.parent = command
commands += builder.build()
}
return command
} }
internal abstract fun create(): COMMAND private fun validateCommand(command: Command) {
if (commands.isNotEmpty()) {
private fun validateCommand(name: String) { val last = commands.last()
if (builders.isNotEmpty()) { if (last is Fallback<*>) throw InvalidCommandOrder()
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)
} }
if (commands.any { it.name == command.name }) throw DuplicateCommand(command)
} }
private fun validateArgument(argument: Argument<*>) { private fun validateArgument(argument: Argument<*>) {
@@ -205,15 +160,12 @@ sealed class Command private constructor(
argument.required && last.optional -> throw InvalidArgumentOrder() argument.required && last.optional -> throw InvalidArgumentOrder()
} }
} }
if (!argument.name.matches(ARGUMENT)) throw InvalidArgument(argument.name) if (arguments.any { it.name == argument.name }) throw DuplicateArgument(argument)
} }
private fun validateOption(option: Option<*>) { private fun validateOption(option: Option<*>) {
for (name in option.names) { for (name in option.names) {
when { if (options.any { name in it.names }) throw DuplicateOption(option)
!name.matches(OPTION) -> throw InvalidOption(name)
options.any { name in it.names } -> throw DuplicateOption(name)
}
} }
} }
@@ -222,14 +174,11 @@ sealed class Command private constructor(
} }
private fun validateRenderer() { private fun validateRenderer() {
if (_renderer != null) throw Exception() //TODO if (_renderer != null) throw DuplicateRenderer()
} }
private fun validate() { 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()

View File

@@ -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<String>) -> Unit) =
fallback(StringParser, name, description, block)
fun int(name: String, description: String? = null, block: Builder<*>.(Fallback.Key<Int>) -> Unit) =
fallback(IntParser, name, description, block)
fun long(name: String, description: String? = null, block: Builder<*>.(Fallback.Key<Long>) -> Unit) =
fallback(LongParser, name, description, block)
fun float(name: String, description: String? = null, block: Builder<*>.(Fallback.Key<Float>) -> Unit) =
fallback(FloatParser, name, description, block)
fun double(name: String, description: String? = null, block: Builder<*>.(Fallback.Key<Double>) -> Unit) =
fallback(DoubleParser, name, description, block)
fun boolean(name: String, description: String? = null, block: Builder<*>.(Fallback.Key<Boolean>) -> Unit) =
fallback(BooleanParser, name, description, block)
inline fun <reified T : Enum<T>> enum(
name: String,
description: String,
noinline block: Builder<*>.(Fallback.Key<T>) -> Unit,
) =
fallback(enumParser<T>(), name, description, block)
}
fun <T : Any> fallback(
parser: Parser<T>,
name: String,
description: String? = null,
block: Fallback.Builder<T>.(Fallback.Key<T>) -> 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()

View File

@@ -1,55 +0,0 @@
package com.infendro.cli.command.argument
import com.infendro.cli.exception.build.InvalidRange
import com.infendro.cli.parser.Parser
sealed class Argument<T : Any>(
val parser: Parser<T>,
val name: String,
val min: Int,
val max: Int?,
) {
init {
if (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<T : Any>(
parser: Parser<T>,
name: String,
) : Argument<T>(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<T : Any>(
parser: Parser<T>,
name: String,
val other: T,
) : Argument<T>(parser, name, min = 0, max = 1)
class OrNull<T : Any>(
parser: Parser<T>,
name: String,
) : Argument<T>(parser, name, min = 0, max = 1)
class Variable<T : Any>(
parser: Parser<T>,
name: String,
min: Int,
max: Int?,
) : Argument<T>(parser, name, min, max)
}

View File

@@ -1,19 +0,0 @@
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 <reified T : Enum<T>> enum(name: String) = argument(enumParser<T>(), name)
}
val argument = ArgumentFactory
fun <T : Any> argument(parser: Parser<T>, name: String) =
Argument.Required(parser, name)

View File

@@ -2,21 +2,24 @@ package com.infendro.cli.command.context
import com.infendro.cli.Dsl import com.infendro.cli.Dsl
import com.infendro.cli.command.Command import com.infendro.cli.command.Command
import com.infendro.cli.command.argument.Argument import com.infendro.cli.argument.Argument
import com.infendro.cli.command.option.Option import com.infendro.cli.option.Option
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@Dsl @Dsl
class Context internal constructor( class Context internal constructor(
val command: Command, private val path: List<Command>,
private val commands: Map<Command.Fallback.Key<*>, Any>, private val commands: Map<Command.Fallback.Key<*>, Any>,
private val arguments: Map<Argument<*>, List<Any>>, private val arguments: Map<Argument<*>, List<Any>>,
private val options: Map<Option<*>, List<Any>>, private val options: Map<Option<*>, List<Any>>,
) { ) {
val command: Command
get() = path.last()
internal fun execute() = (command.execute)() internal fun execute() = (command.execute)()
fun help() { fun help() {
command.help?.let { print(it) } command.renderer.render(path)?.let { print(it) }
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

@@ -1,7 +1,7 @@
package com.infendro.cli.command.context package com.infendro.cli.command.context
import com.infendro.cli.command.Command import com.infendro.cli.command.Command
import com.infendro.cli.exception.run.* import com.infendro.cli.error.*
import com.infendro.cli.parser.Parser import com.infendro.cli.parser.Parser
internal class ContextParser( internal class ContextParser(
@@ -14,24 +14,24 @@ internal class ContextParser(
) : Result() ) : Result()
data class Help( data class Help(
val command: Command, val path: List<Command>,
) : Result() ) : Result()
data class Failure( data class Failure(
val command: Command, val path: List<Command>,
val error: RuntimeError, val error: Error,
) : Result() ) : Result()
} }
private fun success(context: Context) = Result.Success(context) private fun success(context: Context) = Result.Success(context)
private fun help(command: Command) = Result.Help(command) private fun help(path: List<Command>) = Result.Help(path)
private fun failure(command: Command, exception: RuntimeError) = Result.Failure(command, exception) private fun failure(path: List<Command>, error: Error) = Result.Failure(path, error)
private var index = 0 private var index = 0
private val current: String private val current: String
get() = args[index] get() = args[index]
private val next: String? private val next: String?
get() = args.getOrNull(index) get() = args.getOrNull(index + 1)
private fun hasNext() = index < args.size private fun hasNext() = index < args.size
private fun consume() { private fun consume() {
@@ -41,6 +41,7 @@ internal class ContextParser(
fun parse(): Result { fun parse(): Result {
// command // command
var command = root var command = root
val path = mutableListOf(root)
val commands = mutableMapOf<Command.Fallback.Key<*>, Any>() val commands = mutableMapOf<Command.Fallback.Key<*>, Any>()
while (hasNext()) { while (hasNext()) {
@@ -52,16 +53,17 @@ internal class ContextParser(
} }
if (cmd == null) { if (cmd == null) {
when { when {
command.arguments.isEmpty() -> return failure(command, UnknownCommand(current)) command.arguments.isEmpty() -> return failure(path, UnknownCommand(command, current))
else -> break else -> break
} }
} }
command = cmd command = cmd
path += cmd
if (cmd is Command.Fallback<*>) { if (cmd is Command.Fallback<*>) {
val value = when (val result = cmd.parser.parse(current)) { val value = when (val result = cmd.parser.parse(current)) {
is Parser.Result.Success<*> -> result.value 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 commands[cmd.key] = value
} }
@@ -85,11 +87,11 @@ internal class ContextParser(
// argument // argument
endOfOptions || dashes == 0 -> { endOfOptions || dashes == 0 -> {
val argument = command.arguments.getOrNull(argumentIndex) val argument = command.arguments.getOrNull(argumentIndex)
?: return failure(command, 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.Success<*> -> result.value
is Parser.Result.Failure<*> -> return failure(command, result.error) is Parser.Result.Failure<*> -> return failure(path, result.error)
} }
arguments[argument]!! += value arguments[argument]!! += value
argumentCount++ argumentCount++
@@ -109,7 +111,7 @@ internal class ContextParser(
} }
if (dashes == 2 && trimmed == "help") if (dashes == 2 && trimmed == "help")
return help(command) return help(path)
val (name, text) = when { val (name, text) = when {
trimmed.contains('=') -> { trimmed.contains('=') -> {
@@ -119,7 +121,7 @@ internal class ContextParser(
else -> { else -> {
if (next?.startsWith('-') == false) { if (next?.startsWith('-') == false) {
consume() consume()
trimmed to next trimmed to current
} else { } else {
trimmed to null trimmed to null
} }
@@ -133,16 +135,16 @@ internal class ContextParser(
for (name in names) { for (name in names) {
val option = command.options.firstOrNull { name in it.names } val option = command.options.firstOrNull { name in it.names }
?: return failure(command, UnknownOption(name)) ?: return failure(path, UnknownOption(command, name))
val value = when { val value = when {
text != null -> 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.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!! option.flag -> option.fallback!!
else -> return failure(command, MissingOptionValue(option)) else -> return failure(path, MissingOptionValue(option))
} }
options[option]!! += value options[option]!! += value
} }
@@ -153,9 +155,9 @@ internal class ContextParser(
when { when {
current.contains('=') -> { current.contains('=') -> {
val (option) = current.split('=', limit = 2) 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 // validate arguments
for (argument in command.arguments) { for (argument in command.arguments) {
val count = arguments[argument]!!.count() 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 // validate options
for (option in command.options) { for (option in command.options) {
val count = options[option]!!.count() 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) return success(context)
} }
} }

View File

@@ -1,12 +1,19 @@
package com.infendro.cli.command.help package com.infendro.cli.command.help
import com.infendro.cli.command.Command import com.infendro.cli.command.Command
import com.infendro.cli.command.argument.Argument import com.infendro.cli.argument.Argument
import com.infendro.cli.command.option.Option import com.infendro.cli.option.Option
import kotlin.reflect.KClass
object DefaultHelpRenderer : HelpRenderer { object DefaultHelpRenderer : HelpRenderer {
override fun render(command: Command) = buildString { override fun render(path: List<Command>) = buildString {
usage(command) val command = path.last()
usage(path)
if (command.description != null) {
appendLine()
appendLine(command.description)
}
if (command.commands.isNotEmpty()) { if (command.commands.isNotEmpty()) {
appendLine() appendLine()
commands(command.commands) commands(command.commands)
@@ -21,9 +28,11 @@ object DefaultHelpRenderer : HelpRenderer {
} }
} }
private fun StringBuilder.usage(command: Command) { private fun StringBuilder.usage(path: List<Command>) {
val command = path.last()
append("Usage:") append("Usage:")
for (command in command.path) { for (command in path) {
append(" ") append(" ")
when { when {
command is Command.Fallback<*> -> append("<${command.name}>") command is Command.Fallback<*> -> append("<${command.name}>")
@@ -36,11 +45,7 @@ object DefaultHelpRenderer : HelpRenderer {
argument.min == 1 && argument.max == 1 -> append("<${argument.name}>") argument.min == 1 && argument.max == 1 -> append("<${argument.name}>")
argument.min == 0 && argument.max == 1 -> append("[<${argument.name}>]") argument.min == 0 && argument.max == 1 -> append("[<${argument.name}>]")
else -> { else -> {
val range = when { val range = renderRange(argument.min, argument.max)
argument.unbounded -> "{${argument.min},}"
argument.min == argument.max -> "{${argument.min}}"
else -> "{${argument.min},${argument.max}}"
}
append("<${argument.name}>$range") append("<${argument.name}>$range")
} }
} }
@@ -60,11 +65,7 @@ object DefaultHelpRenderer : HelpRenderer {
option.min == 1 && option.max == 1 -> append("($text)") option.min == 1 && option.max == 1 -> append("($text)")
option.min == 0 && option.max == 1 -> append("[$text]") option.min == 0 && option.max == 1 -> append("[$text]")
else -> { else -> {
val range = when { val range = renderRange(option.min, option.max)
option.unbounded -> "{${option.min},}"
option.min == option.max -> "{${option.min}}"
else -> "{${option.min},${option.max}}"
}
append("($text)$range") append("($text)$range")
} }
} }
@@ -73,24 +74,66 @@ object DefaultHelpRenderer : HelpRenderer {
} }
private fun StringBuilder.commands(commands: List<Command>) { private fun StringBuilder.commands(commands: List<Command>) {
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:") appendLine("Commands:")
for (command in commands) { when {
appendLine(" * ${command.name}") 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<Argument<*>>) { private fun StringBuilder.arguments(arguments: List<Argument<*>>) {
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:") appendLine("Arguments:")
for (argument in arguments) { for (row in rows) {
appendLine(" * ${argument.name}") appendLine(" ${row.name.padEnd(nameWidth)} ${row.constraints.padEnd(constraintsWidth)} ${row.description ?: ""}")
} }
} }
private fun StringBuilder.options(options: List<Option<*>>) { private fun StringBuilder.options(options: List<Option<*>>) {
appendLine("Options:") val rows = options.map { option ->
for (option in options) {
val name = option.names.joinToString(" | ") { it.withPrefix() } val name = option.names.joinToString(" | ") { it.withPrefix() }
appendLine(" * $name") 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 ?: ""}")
} }
} }
@@ -98,4 +141,49 @@ object DefaultHelpRenderer : HelpRenderer {
length == 1 -> "-$this" length == 1 -> "-$this"
else -> "--$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"
}
} }

View File

@@ -3,5 +3,5 @@ package com.infendro.cli.command.help
import com.infendro.cli.command.Command import com.infendro.cli.command.Command
interface HelpRenderer { interface HelpRenderer {
fun render(command: Command): String? fun render(path: List<Command>): String?
} }

View File

@@ -0,0 +1,7 @@
package com.infendro.cli.command.help
import com.infendro.cli.command.Command
object NoOpHelpRenderer : HelpRenderer {
override fun render(path: List<Command>) = null
}

View File

@@ -1,7 +0,0 @@
package com.infendro.cli.command.help
import com.infendro.cli.command.Command
object NoopHelpRenderer : HelpRenderer {
override fun render(command: Command) = null
}

View File

@@ -1,22 +0,0 @@
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 <reified T : Enum<T>> enum(vararg names: String) = option(enumParser<T>(), *names)
}
val option = OptionFactory
fun <T : Any> option(parser: Parser<T>, vararg names: String) =
Option.Required(parser, names.asList())
fun <T : Any> option(parser: Parser<T>, fallback: T, vararg names: String) =
Option.Required(parser, fallback, names.asList())

View File

@@ -0,0 +1,5 @@
package com.infendro.cli.error
abstract class Error {
abstract val message: String
}

View File

@@ -1,11 +1,11 @@
package com.infendro.cli.exception.run package com.infendro.cli.error
import com.infendro.cli.command.argument.Argument import com.infendro.cli.argument.Argument
class InvalidArgumentCount( class InvalidArgumentCount(
val argument: Argument<*>, val argument: Argument<*>,
val count: Int, val count: Int,
) : RuntimeError() { ) : Error() {
override val message: String override val message: String
get() { get() {
val expected = when { val expected = when {
@@ -13,6 +13,6 @@ class InvalidArgumentCount(
argument.unbounded -> "at least ${argument.min}" argument.unbounded -> "at least ${argument.min}"
else -> "${argument.min} to ${argument.max}" 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."""
} }
} }

View File

@@ -1,11 +1,11 @@
package com.infendro.cli.exception.run package com.infendro.cli.error
import com.infendro.cli.command.option.Option import com.infendro.cli.option.Option
class InvalidOptionCount( class InvalidOptionCount(
val option: Option<*>, val option: Option<*>,
val count: Int, val count: Int,
) : RuntimeError() { ) : Error() {
override val message: String override val message: String
get() { get() {
val expected = when { val expected = when {
@@ -13,6 +13,6 @@ class InvalidOptionCount(
option.unbounded -> "at least ${option.min}" option.unbounded -> "at least ${option.min}"
else -> "${option.min} to ${option.max}" 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."""
} }
} }

View File

@@ -1,11 +1,11 @@
package com.infendro.cli.exception.run package com.infendro.cli.error
import kotlin.reflect.KClass import kotlin.reflect.KClass
class InvalidValue( class InvalidValue(
val value: String, val value: String,
val type: KClass<*>, val type: KClass<*>,
) : RuntimeError() { ) : Error() {
override val message: String override val message: String
get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}""" get() = """"$value" cannot be converted to ${type.simpleName ?: "Unknown"}."""
} }

View File

@@ -0,0 +1,8 @@
package com.infendro.cli.error
class MalformedOption(
val option: String,
) : Error() {
override val message: String
get() = """Malformed option "$option"."""
}

View File

@@ -0,0 +1,10 @@
package com.infendro.cli.error
import com.infendro.cli.option.Option
class MissingOptionValue(
val option: Option<*>,
) : Error() {
override val message: String
get() = """No value provided for option "${option.name}"."""
}

View File

@@ -0,0 +1,8 @@
package com.infendro.cli.error
class UnexpectedArgument(
val argument: String,
) : Error() {
override val message: String
get() = """Unexpected argument "$argument"."""
}

View File

@@ -0,0 +1,24 @@
package com.infendro.cli.error
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,
) : Error() {
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"?""")
}
}

View File

@@ -0,0 +1,24 @@
package com.infendro.cli.error
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,
) : Error() {
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"?""")
}
}

View File

@@ -0,0 +1,5 @@
package com.infendro.cli.exception
abstract class BuildException : Exception() {
abstract override val message: String
}

View File

@@ -0,0 +1,10 @@
package com.infendro.cli.exception
import com.infendro.cli.argument.Argument
class DuplicateArgument(
val argument: Argument<*>,
) : BuildException() {
override val message: String
get() = """duplicate argument "${argument.name}""""
}

View File

@@ -0,0 +1,10 @@
package com.infendro.cli.exception
import com.infendro.cli.command.Command
class DuplicateCommand(
val command: Command,
) : BuildException() {
override val message: String
get() = """duplicate command "${command.name}""""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class DuplicateExecute : BuildException() {
override val message: String
get() = """duplicate execute block"""
}

View File

@@ -0,0 +1,10 @@
package com.infendro.cli.exception
import com.infendro.cli.option.Option
class DuplicateOption(
val option: Option<*>,
) : BuildException() {
override val message: String
get() = """duplicate option "${option.name}""""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class DuplicateRenderer : BuildException() {
override val message: String
get() = """duplicate renderer"""
}

View File

@@ -0,0 +1,8 @@
package com.infendro.cli.exception
class InvalidArgumentName(
val name: String,
) : BuildException() {
override val message: String
get() = """invalid argument name "$name""""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class InvalidArgumentOrder : BuildException() {
override val message: String
get() = """invalid argument order"""
}

View File

@@ -0,0 +1,8 @@
package com.infendro.cli.exception
class InvalidCommandName(
val name: String,
) : BuildException() {
override val message: String
get() = """invalid command name "$name""""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class InvalidCommandOrder : BuildException() {
override val message: String
get() = """invalid command order"""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class InvalidFallback : BuildException() {
override val message: String
get() = """invalid fallback"""
}

View File

@@ -0,0 +1,8 @@
package com.infendro.cli.exception
class InvalidOptionName(
val name: String,
) : BuildException() {
override val message: String
get() = """invalid option name "$name""""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class InvalidRange : BuildException() {
override val message: String
get() = """invalid range"""
}

View File

@@ -0,0 +1,6 @@
package com.infendro.cli.exception
class MissingOptionName : BuildException() {
override val message: String
get() = """missing option name"""
}

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
abstract class CliException : Exception()

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.build
class DuplicateCommand(
val command: String,
) : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class DuplicateExecute : CliException()

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.build
class DuplicateOption(
val name: String,
) : CliException()

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.build
class InvalidArgument(
val name: String,
) : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class InvalidArgumentOrder : CliException()

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.build
class InvalidCommand(
val name: String,
) : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class InvalidCommandOrder : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class InvalidFallback : CliException()

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.build
class InvalidOption(
val name: String,
) : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class InvalidRange : CliException()

View File

@@ -1,3 +0,0 @@
package com.infendro.cli.exception.build
class MissingOptionName : CliException()

View File

@@ -1,8 +0,0 @@
package com.infendro.cli.exception.run
class MalformedOption(
val option: String,
) : RuntimeError() {
override val message: String
get() = """malformed option "$option""""
}

View File

@@ -1,10 +0,0 @@
package com.infendro.cli.exception.run
import com.infendro.cli.command.option.Option
class MissingOptionValue(
val option: Option<*>,
) : RuntimeError() {
override val message: String
get() = """no value provided for option "${option.name}""""
}

View File

@@ -1,5 +0,0 @@
package com.infendro.cli.exception.run
abstract class RuntimeError {
abstract val message: String
}

View File

@@ -1,6 +0,0 @@
package com.infendro.cli.exception.run
class UnexpectedArgument : RuntimeError() {
override val message: String
get() = """unexpected argument"""
}

View File

@@ -1,8 +0,0 @@
package com.infendro.cli.exception.run
class UnknownCommand(
val command: String,
) : RuntimeError() {
override val message: String
get() = """unknown command "$command""""
}

View File

@@ -1,8 +0,0 @@
package com.infendro.cli.exception.run
class UnknownOption(
val option: String,
) : RuntimeError() {
override val message: String
get() = """unknown option "$option""""
}

View File

@@ -1,13 +1,16 @@
package com.infendro.cli.command.option package com.infendro.cli.option
import com.infendro.cli.exception.build.InvalidRange import com.infendro.cli.exception.InvalidOptionName
import com.infendro.cli.exception.build.MissingOptionName import com.infendro.cli.exception.InvalidRange
import com.infendro.cli.exception.MissingOptionName
import com.infendro.cli.parser.Parser import com.infendro.cli.parser.Parser
import com.infendro.cli.util.Regex.OPTION
sealed class Option<T : Any>( sealed class Option<T : Any>(
val parser: Parser<T>, val parser: Parser<T>,
val fallback: T?, val fallback: T?,
val names: List<String>, val names: List<String>,
val description: String?,
val min: Int, val min: Int,
val max: Int?, val max: Int?,
) { ) {
@@ -33,6 +36,7 @@ sealed class Option<T : Any>(
init { init {
when { when {
names.isEmpty() -> throw MissingOptionName() names.isEmpty() -> throw MissingOptionName()
names.any { !it.matches(OPTION) } -> throw InvalidOptionName(name)
min !in 0..count || max == 0 -> throw InvalidRange() min !in 0..count || max == 0 -> throw InvalidRange()
} }
} }
@@ -41,53 +45,39 @@ sealed class Option<T : Any>(
parser: Parser<T>, parser: Parser<T>,
fallback: T?, fallback: T?,
names: List<String>, names: List<String>,
) : Option<T>(parser, fallback, names, min = 1, max = 1) { description: String?,
constructor( ) : Option<T>(parser, fallback, names, description, min = 1, max = 1) {
parser: Parser<T>, fun orElse(other: T) =
names: List<String>, OrElse(parser, fallback, names, description, other)
) : this(parser, null, names)
fun orElse(other: T) = OrElse(parser, fallback, names, other) fun orNull() =
fun orNull() = OrNull(parser, fallback, names) OrNull(parser, fallback, names, description)
fun variable(min: Int = 0, max: Int? = null) = Variable(parser, fallback, names, min, max)
fun variable(min: Int = 0, max: Int? = null) =
Variable(parser, fallback, names, description, min, max)
} }
class OrElse<T : Any>( class OrElse<T : Any>(
parser: Parser<T>, parser: Parser<T>,
fallback: T?, fallback: T?,
names: List<String>, names: List<String>,
description: String?,
val other: T, val other: T,
) : Option<T>(parser, fallback, names, min = 0, max = 1) { ) : Option<T>(parser, fallback, names, description, min = 0, max = 1)
constructor(
parser: Parser<T>,
names: List<String>,
other: T,
) : this(parser, null, names, other)
}
class OrNull<T : Any>( class OrNull<T : Any>(
parser: Parser<T>, parser: Parser<T>,
fallback: T?, fallback: T?,
names: List<String>, names: List<String>,
) : Option<T>(parser, fallback, names, min = 0, max = 1) { description: String?,
constructor( ) : Option<T>(parser, fallback, names, description, min = 0, max = 1)
parser: Parser<T>,
names: List<String>,
) : this(parser, null, names)
}
class Variable<T : Any>( class Variable<T : Any>(
parser: Parser<T>, parser: Parser<T>,
fallback: T?, fallback: T?,
names: List<String>, names: List<String>,
description: String?,
min: Int, min: Int,
max: Int?, max: Int?,
) : Option<T>(parser, fallback, names, min, max) { ) : Option<T>(parser, fallback, names, description, min, max)
constructor(
parser: Parser<T>,
names: List<String>,
min: Int,
max: Int?,
) : this(parser, null, names, min, max)
}
} }

View File

@@ -0,0 +1,34 @@
package com.infendro.cli.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 <reified T : Enum<T>> enum(vararg names: String, description: String? = null) =
option(enumParser<T>(), *names, description = description)
}
val option = OptionFactory
fun <T : Any> option(parser: Parser<T>, vararg names: String, description: String? = null) =
Option.Required(parser, fallback = null, names.asList(), description)
fun <T : Any> option(parser: Parser<T>, fallback: T, vararg names: String, description: String? = null) =
Option.Required(parser, fallback, names.asList(), description)

View File

@@ -1,7 +1,7 @@
package com.infendro.cli.parser package com.infendro.cli.parser
import com.infendro.cli.exception.run.InvalidValue import com.infendro.cli.error.Error
import com.infendro.cli.exception.run.RuntimeError import com.infendro.cli.error.InvalidValue
import kotlin.reflect.KClass import kotlin.reflect.KClass
abstract class Parser<T : Any>( abstract class Parser<T : Any>(
@@ -13,7 +13,7 @@ abstract class Parser<T : Any>(
) : Result<T>() ) : Result<T>()
data class Failure<T : Any>( data class Failure<T : Any>(
val error: RuntimeError, val error: Error,
) : Result<T>() ) : Result<T>()
} }

View File

@@ -0,0 +1,54 @@
package com.infendro.cli.util
internal const val threshold = 2
/**
* Calculates the Damerau-Levenshtein distance between two strings.
*
* The distance is the minimum number of operations (insertion, deletion, substitution, and transposition) required to transform one string into another.
*
* @param a The source string.
* @param b The target string.
* @return The minimum number of operations to transform [a] into [b].
*/
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]
}