diff --git a/src/main/kotlin/net/keinekekse/args/impl.kt b/src/main/kotlin/net/keinekekse/args/impl.kt new file mode 100644 index 0000000..c68c296 --- /dev/null +++ b/src/main/kotlin/net/keinekekse/args/impl.kt @@ -0,0 +1,232 @@ +package net.keinekekse.args + +import kotlin.reflect.KProperty +import kotlin.system.exitProcess + +interface ValueDelegate { + var value: T? + operator fun getValue(thisRef: Any?, property: KProperty<*>): T? = value + + fun updateValue(value: T?) { + this.value = value + } + + fun isEmpty() = value == null // needed? +} + +interface MutableValueDelegate : ValueDelegate { + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + this.value = value + } +} + +class ParameterValue : ValueDelegate { + override var value: T? = null +} + +internal sealed class ParameterType { + + abstract fun createOption( + long: String, + short: String?, + description: String, + isMandatory: Boolean, + ): ParameterData + + abstract fun convertAndSet(value: String, opt: ValueDelegate) + + object STRING : ParameterType() { + override fun createOption( + long: String, + short: String?, + description: String, + isMandatory: Boolean, + ): ParameterData { + return ParameterData(long, short, description, isMandatory, ParameterValue(), this) + } + + override fun convertAndSet(value: String, opt: ValueDelegate) { + opt.updateValue(value) + } + } + + object LONG : ParameterType() { + override fun createOption( + long: String, + short: String?, + description: String, + isMandatory: Boolean, + ): ParameterData { + return ParameterData(long, short, description, isMandatory, ParameterValue(), this) + } + + override fun convertAndSet(value: String, opt: ValueDelegate) { + opt.updateValue( + value.trim().toLongOrNull() ?: throw ArgumentParserException.invalidLongValue(value) + ) + } + } + + object BOOLEAN : ParameterType() { + override fun createOption( + long: String, + short: String?, + description: String, + isMandatory: Boolean, + ): ParameterData { + return ParameterData( + long, + short, + description, + false, + ParameterValue().apply { updateValue(false) }, + this + ) + } + + override fun convertAndSet(value: String, opt: ValueDelegate) { + opt.updateValue(value == TRUE) + } + } +} + +internal data class SubCommandData( + val name: String = "", + val description: String = "", + val aliases: List = emptyList(), +// var run: (remaining: List) -> Unit = {}, + val params: MutableMap> = mutableMapOf(), + var additionalParameters: List = emptyList(), +) { + private var exec: RunProgram = {} + private var isSet: Boolean = false + + fun setExecution(run: RunProgram) { + this.exec = run + isSet = true + } + + fun checkExecutors() { + if (!isSet) throw ArgumentParserException.noExecutorDefined(name.ifEmpty { null } + ?: "global parameters") + } + + fun execute(remaining: List) { + exec.run(remaining) + } +} + +internal data class ParameterData( + val long: String, + val short: String? = null, + val description: String = "", + val isMandatory: Boolean = false, + val prop: ParameterValue, + val type: ParameterType, +) + +interface AddParameter { + fun addStringParameter( + long: String, + short: Char? = null, + description: String, + isMandatory: Boolean = false, + ): ParameterValue + + fun addLongParameter( + long: String, + short: Char? = null, + description: String, + isMandatory: Boolean = false, + ): ParameterValue + + fun addBooleanParameter( + long: String, + short: Char? = null, + description: String, + isMandatory: Boolean = false, + ): ParameterValue + + fun setExecution(run: RunProgram) +} + +// Fehler beim Check der Parameter-Definitionen und beim Parsen der Kommandozeile +internal class ArgumentParserException(message: String?) : RuntimeException(message) { + companion object { + const val PARSING_ERROR_MSG = "\n*** ERROR parsing arguments: %s\n" + const val DEFINITION_ERROR_MSG = "\n*** ERROR defining arguments: %s\n" + fun showHelp() = ArgumentParserException(null) + fun noHelpOrVersion() = + ArgumentParserException("Option 'help' and option 'version' are not allowed.") + + fun noParametersDefined() = ArgumentParserException("No parameters found for command line.") + fun noExecutorDefinedGlobal() = ArgumentParserException("No lambda found for command line.") + fun noExecutorDefined(name: String?) = + ArgumentParserException("No lambda found for '$name'.") + + fun optionExists(name: String) = ArgumentParserException("Option '$name' already exists.") + + fun invalidAnnotationTargetParameter(name: String) = + ArgumentParserException("Annotation @CmdLineParameter can only be used on mutable properties, '$name' isn't a mutable property.") + + fun notNullableAnnotationTargetParameter(name: String) = + ArgumentParserException("Annotation @CmdLineParameter can only be used on nullable properties, '$name' isn't a nullable property.") + + fun invalidAnnotationTargetOption() = + ArgumentParserException("Annotation @CmdLineOption can only be used on CommandLine Objects.") + + fun optionNameRequired() = ArgumentParserException("Option name is required.") + fun unknownOption(name: String) = ArgumentParserException("Unknown option '$name'.") + fun unknownCommand(name: String) = ArgumentParserException("Unknown command '$name'.") + fun optionRequiresValue(name: String) = + ArgumentParserException("Option '$name' requires a value.") + + fun optionNoValue(name: String) = + ArgumentParserException("Option '$name' does not accept a value.") + + fun booleanNotMandatory(name: String) = + ArgumentParserException("Boolean option '$name' can not be mandatory.") + + fun optionRequired(name: String) = ArgumentParserException("Option '$name' is required.") + fun commandRequired() = ArgumentParserException("At least one command is required.") + fun invalidLongValue(value: String) = + ArgumentParserException("Value '$value' is not a long integer.") + + fun invalidType(name: String, type: String) = + ArgumentParserException("Unsupported parameter type: ${type} for parameter $name") + + fun invalidShortName(name: String) = + ArgumentParserException("Option short name '$name' is invalid.") + + fun invalidName(name: String) = ArgumentParserException("Option name '$name' is invalid.") + fun invalidCommand(name: String) = + ArgumentParserException("Command name '$name' is invalid.") + + fun noMoreOptions() = ArgumentParserException("Too many arguments.") + fun noMoreParsing() = ArgumentParserException("Arguments have already been parsed.") + } +} + +internal const val INDENT = " " +internal const val TRUE = "true" + +internal const val NAME = """[a-zA-Z][\w-]+""" +internal const val SHORT_NAME = """[a-zA-Z]""" +internal val ARGUMENT_PATTERN = """^(?:-(${SHORT_NAME})|--(${NAME}))(?:=(.+)|$)$""".toRegex() +internal val NAME_PATTERN = """^${NAME}$""".toRegex() +internal val SHORT_NAME_PATTERN = """^${SHORT_NAME}$""".toRegex() + +internal fun String.handleCase(ignoreCase: Boolean): String { + return if (ignoreCase) lowercase() else this +} + +internal fun matchSubCommand(cmd: String) = + NAME_PATTERN.matches(cmd) + +internal fun matchArgument(arg: String): Pair? = + ARGUMENT_PATTERN.matchEntire(arg)?.groupValues?.let { Pair(it[1] + it[2], it[3]) } + +internal fun showDefiningError(msg: String): Nothing { + println(ArgumentParserException.DEFINITION_ERROR_MSG.format(msg)) + exitProcess(1) +} diff --git a/src/main/kotlin/net/keinekekse/args/pub.kt b/src/main/kotlin/net/keinekekse/args/pub.kt new file mode 100644 index 0000000..15ec705 --- /dev/null +++ b/src/main/kotlin/net/keinekekse/args/pub.kt @@ -0,0 +1,352 @@ +package net.keinekekse.args + +import net.keinekekse.version.Version +import kotlin.system.exitProcess + +class VerifyParameterException(message: String) : RuntimeException(message) + +fun showParsingError(msg: String): Nothing { + println(ArgumentParserException.PARSING_ERROR_MSG.format(msg)) + exitProcess(2) +} + +/** + * Functional interface representing a runnable program execution logic. + * + * This interface allows defining a lambda function or block of code that + * processes a list of remaining arguments. It is intended to be used as + * a mechanism to encapsulate the execution logic for commands or subcommands + * in a command-line parsing framework. + * + * The primary use case for this interface is to define execution behavior + * for subcommands or global commands, commonly used in CLI applications. + * + * @constructor A single abstract method is provided to execute the specified logic. + */ +fun interface RunProgram { + fun run(remaining: List) +} + +class SubCommand private constructor( + internal val obj: SubCommandData, + val ignoreCase: Boolean = false, +) : AddParameter { + companion object { + internal fun create(obj: SubCommandData, ignoreCase: Boolean = false): SubCommand = + SubCommand(obj, ignoreCase) + } + + override fun addStringParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return createParameter(ParameterType.STRING, long, short, description, isMandatory) + } + + override fun addLongParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return createParameter(ParameterType.LONG, long, short, description, isMandatory) + } + + override fun addBooleanParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return createParameter(ParameterType.BOOLEAN, long, short, description, isMandatory) + } + + override fun setExecution(run: RunProgram) { + obj.setExecution(run) + } + + private fun createParameter( + type: ParameterType, + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + val result = runCatching { + val long = long.handleCase(ignoreCase) + val short = short?.toString()?.handleCase(ignoreCase) + type.createOption(long, short, description, isMandatory).apply { + verifyParameter(this) + + @Suppress("UNCHECKED_CAST") + obj.params[long] = this as ParameterData + short?.let { + obj.params[it] = this + } + }.prop + } + return result.getOrElse { showDefiningError(it.message ?: "") } + } + + private fun verifyParameter(parameter: ParameterData<*>) { + if (!NAME_PATTERN.matches(parameter.long)) throw ArgumentParserException.invalidName( + parameter.long + ) + if (obj.params.containsKey(parameter.long)) throw ArgumentParserException.optionExists( + parameter.long + ) + parameter.short?.let { + if (!SHORT_NAME_PATTERN.matches(it)) { + throw ArgumentParserException.invalidShortName(it) + } + if (obj.params.containsKey(it)) throw ArgumentParserException.optionExists(it) + } + val n = parameter.long.lowercase() + if (n == "help" || n == "version") { + throw ArgumentParserException.noHelpOrVersion() + } + + } + +} + +class CommandLine( + val programVersion: Version, + val ignoreCase: Boolean = false, + val remainingDescription: String? = null, // null -> no remaining allowed +) : AddParameter { + private val globalSubCommand = SubCommand.create(SubCommandData(), ignoreCase) + private var subCommands = mutableMapOf() + + override fun addStringParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return globalSubCommand.addStringParameter(long, short, description, isMandatory) + } + + override fun addLongParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return globalSubCommand.addLongParameter(long, short, description, isMandatory) + } + + override fun addBooleanParameter( + long: String, + short: Char?, + description: String, + isMandatory: Boolean, + ): ParameterValue { + return globalSubCommand.addBooleanParameter(long, short, description, isMandatory) + } + + override fun setExecution(run: RunProgram) { + globalSubCommand.setExecution(run) + } + + fun addSubCommand( + name: String, + description: String, + aliases: List = emptyList(), + ): SubCommand { + val result = runCatching { + val name = verifySubCommand(name, ignoreCase) + val subData = SubCommandData(name, description, aliases) + subCommands[name] = subData + aliases.forEach { alias -> + val alias = verifySubCommand(alias, ignoreCase) + subCommands[alias] = subData + } + SubCommand.create(subData, ignoreCase) + } + return result.getOrElse { showDefiningError(it.message ?: "") } + } + + fun parseAndRun(vararg args: String) { + val result = runCatching { + val selectedSubCommand = parse(args.toList()) + selectedSubCommand.execute(selectedSubCommand.additionalParameters) + } + result.exceptionOrNull()?.let { showHelp(it as Exception) } + } + + private fun verifySubCommand(cmd: String, ignoreCase: Boolean): String { + val cmd = cmd.handleCase(ignoreCase) + if (!matchSubCommand(cmd)) throw ArgumentParserException.invalidCommand(cmd) + return cmd + } + + private fun parse(args: List): SubCommandData { + if (args.isEmpty()) { + throw ArgumentParserException.commandRequired() + } + checkExecutors() + + val remaining = parseParameters(args, globalSubCommand.obj) + validateMandatoryParameters(globalSubCommand.obj) + + val resultSubCommand = parseSubCommand(remaining) + validateMandatoryParameters(resultSubCommand) + + if (resultSubCommand.additionalParameters.isNotEmpty() && remainingDescription != null) { + throw ArgumentParserException.noMoreOptions() + } + + return resultSubCommand + } + + private fun parseSubCommand(args: List): SubCommandData { + val sub = args.firstOrNull()?.handleCase(ignoreCase)?.let { subCommands[it] } + ?: return globalSubCommand.obj.apply { additionalParameters = args } + return sub.apply { additionalParameters = parseParameters(args.drop(1), this) } + } + + private fun validateMandatoryParameters(subCommand: SubCommandData) { + subCommand.params.forEach { (_, parameter) -> + if (parameter.isMandatory && parameter.prop.value == null) { + throw ArgumentParserException.optionRequiresValue(parameter.long) + } + } + } + + private fun parseParameters(args: List, subCommand: SubCommandData): List { + var index = 0 + while (index < args.size) { + val parseResult = matchArgument(args[index]) ?: return args.drop(index) + + var (key, inlineValue) = parseResult + checkForHelpOrVersion(key) + key = key.handleCase(ignoreCase) + + val param = subCommand.params[key] ?: throw ArgumentParserException.unknownOption(key) + + val (value, consumedExtra) = resolveParameterValue(args, index, key, inlineValue, param) + param.type.convertAndSet(value, param.prop) + + index += 1 + consumedExtra + } + return emptyList() + } + + private fun resolveParameterValue( + args: List, + currentIndex: Int, + key: String, + inlineValue: String, + param: ParameterData, + ): Pair { + return when { + param.type == ParameterType.BOOLEAN -> { + if (inlineValue.isNotBlank()) throw ArgumentParserException.optionNoValue(key) + TRUE to 0 + } + + // --name=--irgendwas ist OK + inlineValue.isNotBlank() -> inlineValue to 0 + + // --name --irgendwas ist nicht OK + currentIndex < args.size - 1 && !ARGUMENT_PATTERN.matches(args[currentIndex + 1]) -> { + args[currentIndex + 1] to 1 + } + + else -> throw ArgumentParserException.optionRequiresValue(key) + } + } + + private fun checkExecutors() { + globalSubCommand.obj.checkExecutors() + subCommands.values.forEach { it.checkExecutors() } + } + + private fun checkForHelpOrVersion(key: String) { + val k = key.lowercase() + if (k == "help") { + throw ArgumentParserException.showHelp() + } else if (k == "version") { + showVersion() + } + } + + private fun showHelp( + e: Exception?, + ): Nothing { + val sub = !subCommands.isEmpty() + val globalParam = globalSubCommand.obj.params.isNotEmpty() + val remain = remainingDescription != null + + println("${programVersion.displayName} ${programVersion.version}, ${programVersion.copyright}\n") + println(programVersion.description) + println() + println( + "usage: ${programVersion.name} ${ + if (sub) + if (globalParam) { + "[GLOBAL PARAMETER]" + } else { + "" + } + " subcommand [PARAMETER]" + else "[PARAMETER]" + } ${if (remain) "<$remainingDescription>" else ""}" + ) + println() + if (globalParam) { + if (sub) { + println("Global Parameters:") + } else { + println("Parameters:") + } + } + showParameters(globalSubCommand.obj.params) + if (sub) { + println() + println("Subcommands:") + showSubcommands(subCommands) + } + e?.message?.let { + println() + println(ArgumentParserException.PARSING_ERROR_MSG.format(it)) + println() + } + exitProcess(0) + } + + private fun showSubcommands(parameters: MutableMap) { + parameters.filter { it.key == it.value.name }.forEach { (_, param) -> + val aliases = buildString { + param.aliases.forEach { alias -> + append(" | $alias") + } + } + println("$INDENT${param.name}$aliases ${param.description}") + showParameters(param.params) + println() + + } + + } + + private fun showParameters(parameters: MutableMap>) { + var len = 0 + parameters.filter { it.key == it.value.long }.map { (_, param) -> + len = maxOf(len, param.long.length + INDENT.length * 2 + 4 + 2) + Pair(INDENT + INDENT + (param.short?.let { "-$it, " } ?: " ") + "--${param.long}", + "${param.description} ${if (param.isMandatory) "*" else ""}") + }.forEach { + println(it.first.padEnd(len + 3) + it.second) + } + + } + + private fun showVersion(): Nothing { + println("${programVersion.displayName} ${programVersion.fullVersion}, ${programVersion.copyright}\n") + exitProcess(0) + } + +}