From d2948e500c3b54394f9c02687d7d838933435927 Mon Sep 17 00:00:00 2001 From: Hartmut Bauer Date: Sun, 18 Jan 2026 02:09:55 +0100 Subject: [PATCH] command line via annotations --- .../net/keinekekse/args/annotation/api.kt | 110 +++++ .../net/keinekekse/args/annotation/impl.kt | 464 ++++++++++++++++++ .../net/keinekekse/args/{ => v1}/arguments.kt | 6 +- 3 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/net/keinekekse/args/annotation/api.kt create mode 100644 src/main/kotlin/net/keinekekse/args/annotation/impl.kt rename src/main/kotlin/net/keinekekse/args/{ => v1}/arguments.kt (99%) diff --git a/src/main/kotlin/net/keinekekse/args/annotation/api.kt b/src/main/kotlin/net/keinekekse/args/annotation/api.kt new file mode 100644 index 0000000..10632ff --- /dev/null +++ b/src/main/kotlin/net/keinekekse/args/annotation/api.kt @@ -0,0 +1,110 @@ +package net.keinekekse.args.annotation + +/** + * Annotation to define metadata for a command-line argument. + * + * This annotation can be applied to properties to specify their representation + * as command-line arguments. It provides options for defining the argument's + * name, shortened single-character alias, description, and whether it is mandatory. + * + * The property must be declared as mutable and nullable. + * + * @property name The full name of the command-line argument. Defaults to the name of the property. + * @property shortName The single-character alias for the argument. Defaults to [Char.MIN_VALUE], + * indicating no short name is defined. + * @property description A brief description of the argument's purpose. Defaults to an empty string. + * @property isMandatory Specifies if the argument is mandatory. Defaults to `false`. + */ +@Target(AnnotationTarget.PROPERTY) +annotation class CmdLineArgument( + val name: String = "", + val shortName: Char = Char.MIN_VALUE, + val description: String = "", + val isMandatory: Boolean = false, +) + +/** + * Annotation to define a subcommand for a command-line interface. + * + * This annotation can be applied to objects to designate them as subcommands + * for a command-line tool. It specifies the subcommand's name, a brief description, + * and optional aliases that can be used to invoke the subcommand. + * + * @property name The primary identifier for the subcommand. Defaults to the simple name of the class. + * @property description A short description of the subcommand's purpose. Defaults to an empty string. + * @property aliases Alternative names that can be used to invoke the subcommand. Defaults to an + * empty array. + */ +@Target(AnnotationTarget.CLASS) +annotation class CmdLineSubCommand( + val name: String = "", + val description: String = "", + val aliases: Array = [], +) + +/** + * Represents a set of parameters associated globally with the program. + * This class adds additional properties and customization options to manage + * the behavior of the command line interpretation, such as allowing additional parameters, + * handling case sensitivity, and keeping track of command execution status. + * + * The parameters have to be annotated with the [CmdLineArgument] annotation. + * Subcommands can be defined using the [CmdLineSubCommand] annotation on objects, + * derived from the [SubCommand] class. + * + * @property programDescription A description of the program's purpose or functionality. + * @property additionalParametersAllowed Indicates whether the program allows additional, + * unspecified parameters during execution. + * @property ignoreCase Determines if parameter matching should be case-insensitive. + */ +abstract class Parameters( + val programDescription: String = "", + val additionalParametersAllowed: Boolean = true, + val ignoreCase: Boolean = false, + var didRun: Boolean = false, +) : SubCommand() + +/** + * Represents an abstract subcommand that can be executed with specific behavior and validation logic. + * + * Subclasses of this class are expected to provide implementation for validating their parameters + * and defining the execution logic of the subcommand. Each subcommand can also handle additional + * parameters. + * + * @property additionalParameters A list of additional command line parameters associated with this + * subcommand. + */ +abstract class SubCommand { + var additionalParameters: List = emptyList() + + abstract fun verifyParameters(): Boolean + abstract fun run(globals: Parameters) +} + +/** + * Executes the program logic using the specified command-line arguments. + * This method internally invokes the `run` method of the `Parameters` class + * to parse, validate, and execute the input arguments. + * + * @param args A list of command-line arguments to be processed by the program. + */ +fun Parameters.runProgramWith(args: List) { + run(args) +} + +/** + * Exception thrown to indicate that there is an error related to verifying + * the validity of command-line parameters provided to the application. + * + * This exception is thrown by the [SubCommand.verifyParameters] method to indicate + * that the parameters provided to the subcommand are invalid. + * + * The exception is caught by the [Parameters.runProgramWith] method and the program + * execution will be terminated with an error message and the usage information. + * + * @constructor + * Constructs a new [VerifyParameterException] with the specified detail message. + * + * @param message The detail message describing the parameter verification error. + */ +class VerifyParameterException(message: String) : RuntimeException(message) diff --git a/src/main/kotlin/net/keinekekse/args/annotation/impl.kt b/src/main/kotlin/net/keinekekse/args/annotation/impl.kt new file mode 100644 index 0000000..5a784ec --- /dev/null +++ b/src/main/kotlin/net/keinekekse/args/annotation/impl.kt @@ -0,0 +1,464 @@ +package net.keinekekse.args.annotation + +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible +import kotlin.system.exitProcess + +// entry point +fun Parameters.run(args: List) { + var allParameters: Map = emptyMap() + try { + if (didRun) throw ArgumentParserException.noMoreParsing() else didRun = true + allParameters = fetchAllParameters() + + val subCommand = parseCommandLine(args, allParameters, ignoreCase) + if (subCommand.verifyParameters()) { + subCommand.run(this) + } + } catch (e: ArgumentParserException) { + showHelp(e, allParameters, programDescription) + } catch (e: VerifyParameterException) { + showHelp(e, allParameters, programDescription) + } +} + +// Fehler beim Check der Parameter-Definitionen und beim Parsen der Kommandozeile +private class ArgumentParserException(message: String?) : RuntimeException(message) { + companion object { + const val ERROR_MSG = "\n*** ERROR parsing 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 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.") + } +} + +private const val GLOBAL_PARAMETERS = "#global#" +private const val TRUE = "true" + +private const val NAME = """[a-zA-Z][\w-]+""" +private const val SHORT_NAME = """[a-zA-Z]""" +private val ARGUMENT_PATTERN = """^(?:-(${SHORT_NAME})|--(${NAME}))(?:=(.+)|$)$""".toRegex() +private val NAME_PATTERN = """^${NAME}$""".toRegex() +private val SHORT_NAME_PATTERN = """^${SHORT_NAME}$""".toRegex() + +private sealed interface ParameterType { + fun convertAndSet( + value: String, + obj: SubCommand, + prop: KMutableProperty1, + ) + + object STRING : ParameterType { + override fun convertAndSet( + value: String, + obj: SubCommand, + prop: KMutableProperty1, + ) { + prop.setter.call(obj, value) + } + } + + object LONG : ParameterType { + override fun convertAndSet( + value: String, + obj: SubCommand, + prop: KMutableProperty1, + ) { + val valueLong = + value.trim().toLongOrNull() ?: throw ArgumentParserException.invalidLongValue(value) + prop.setter.call(obj, valueLong) + } + } + + object BOOLEAN : ParameterType { + override fun convertAndSet( + value: String, + obj: SubCommand, + prop: KMutableProperty1, + ) { + val valueBool = value.toBoolean() + prop.setter.call(obj, valueBool) + } + } +} + +private typealias CmdParameterMap = Map +private typealias CmdSubCommandMap = Map + +private data class CmdSubCommand( + val option: String = GLOBAL_PARAMETERS, + val description: String = "", + val aliases: List = emptyList(), + val obj: SubCommand, + val params: CmdParameterMap, +) + +private data class CmdParameter( + val long: String, + val short: Char? = null, + val description: String = "", + val isMandatory: Boolean = false, + val type: ParameterType, + val prop: KMutableProperty1, +) + +// handle annotations + +private fun Parameters.fetchAllParameters(): CmdSubCommandMap { + verifyParametersExist() + val globalParams = getParameters(ignoreCase) + + return mutableMapOf().apply { + put(GLOBAL_PARAMETERS, CmdSubCommand(obj = this@fetchAllParameters, params = globalParams)) + getSubCommands(this, ignoreCase) + } +} + +private fun SubCommand.getParameters(ignoreCase: Boolean): CmdParameterMap { + val params = mutableMapOf() + this::class.memberProperties + .filter { it.hasAnnotation() } + .forEach { property -> + val mutableProp = validateProperty(property) + + val param = mutableProp.toCmdParameter(this, ignoreCase) + if (params.containsKey(param.long)) throw ArgumentParserException.optionExists(param.long) + params[param.long] = param + + param.short?.let { + val s = it.toString() + if (params.containsKey(s)) throw ArgumentParserException.optionExists(s) + params[s] = param + } + } + return params +} + +private fun KMutableProperty1.toCmdParameter( + instance: SubCommand, + ignoreCase: Boolean, +): CmdParameter { + val annotation = findAnnotation()!! + + val name = (annotation.name.takeIf { it.isNotBlank() } ?: name).handle(ignoreCase) + verifyNoHelpOrVersion(name) + if (!NAME_PATTERN.matches(name)) throw ArgumentParserException.invalidName(name) + + val short = annotation.shortName.takeIf { it.isLetter() || it.isDigit() }?.let { + if (!SHORT_NAME_PATTERN.matches(it.toString())) { + throw ArgumentParserException.invalidShortName(it.toString()) + } + if (ignoreCase) it.lowercaseChar() else it + } + + return CmdParameter( + long = name, + short = short, + description = annotation.description, + isMandatory = annotation.isMandatory, + type = this.determineParameterType(name, annotation.isMandatory, instance), + prop = this + ) +} + +private fun KMutableProperty1.determineParameterType( + name: String, + isMandatory: Boolean, + instance: SubCommand, +): ParameterType { + return when (returnType.classifier) { + String::class -> ParameterType.STRING + Long::class -> ParameterType.LONG + Boolean::class -> { + if (isMandatory) throw ArgumentParserException.booleanNotMandatory(name) + ParameterType.BOOLEAN.also { it.convertAndSet("false", instance, this) } + } + + else -> throw ArgumentParserException.invalidType(name, returnType.toString()) + } +} + +private fun validateProperty(property: KProperty1): KMutableProperty1 { + // 1. mutable + val mutableProp = property as? KMutableProperty1 + ?: throw ArgumentParserException.invalidAnnotationTargetParameter(property.name) + // 2. nullable + if (!mutableProp.returnType.isMarkedNullable) throw ArgumentParserException.notNullableAnnotationTargetParameter( + property.name + ) + // 3. override 'private' + mutableProp.isAccessible = true + + return mutableProp +} + +private fun SubCommand.getSubCommands( + optionList: MutableMap, + ignoreCase: Boolean, +) { + val subCommandClasses = + this::class.nestedClasses.filter { it.hasAnnotation() } + + subCommandClasses.forEach { kClass -> + val annotation = kClass.findAnnotation()!! + val cmdObj = kClass.objectInstance as? SubCommand + ?: throw ArgumentParserException.invalidAnnotationTargetOption() + + val name = resolveCommandName(kClass, annotation, ignoreCase) + val cmdSubCommand = CmdSubCommand( + option = name, + description = annotation.description, + aliases = annotation.aliases.toList().map { it.handle(ignoreCase) }, + obj = cmdObj, + params = cmdObj.getParameters(ignoreCase) + ) + + optionList[name] = cmdSubCommand + annotation.aliases.forEach { alias -> + optionList[alias] = cmdSubCommand + } + } +} + +private fun resolveCommandName( + kClass: kotlin.reflect.KClass<*>, + annotation: CmdLineSubCommand, + ignoreCase: Boolean, +): String { + val rawName = annotation.name.takeIf { it.isNotBlank() } + ?: kClass.simpleName + ?: throw ArgumentParserException.optionNameRequired() + + return rawName.handle(ignoreCase) +} + +private fun Parameters.verifyParametersExist() { + val me = this::class + val hasParam = me.memberProperties.any { it.hasAnnotation() } + val hasSubcommand = me.nestedClasses.any { it.hasAnnotation() } + if (!hasParam && !hasSubcommand) { + throw ArgumentParserException.noParametersDefined() + } +} + +private fun verifyNoHelpOrVersion(name: String) { + val n = name.lowercase() + if (n == "help" || n == "version") { + throw ArgumentParserException.noHelpOrVersion() + } +} + +// parse command line arguments + +private fun Parameters.parseCommandLine( + args: List, + parameters: CmdSubCommandMap, + ignoreCase: Boolean, +): SubCommand { + if (args.isEmpty()) { + throw ArgumentParserException.commandRequired() + } + + val globals = parameters[GLOBAL_PARAMETERS]!! + val remaining = parseParameters(args, globals, ignoreCase) + validateMandatoryParameters(globals) + + val resultSubCommand = if (parameters.size > 1) { + parseSubCommand(remaining, parameters, ignoreCase) + } else { + globals.obj.apply { additionalParameters = remaining } + } + + if (resultSubCommand.additionalParameters.isNotEmpty() && !additionalParametersAllowed) { + throw ArgumentParserException.noMoreOptions() + } + + return resultSubCommand +} + +private fun parseSubCommand( + args: List, + parameters: CmdSubCommandMap, + ignoreCase: Boolean, +): SubCommand { + val subCommandData = getSubCommand(args, parameters, ignoreCase) + val remainingAfterSub = parseParameters(args.drop(1), subCommandData, ignoreCase) + return subCommandData.obj.apply { + additionalParameters = remainingAfterSub + } +} + +// property has to be nullable for this to work +private fun validateMandatoryParameters(arguments: CmdSubCommand) { + arguments.params.forEach { (_, param) -> + if (param.isMandatory && param.prop.getter.call(arguments.obj) == null) { + throw ArgumentParserException.optionRequired(param.long) + } + } +} + +private fun getSubCommand( + args: List, + parameters: CmdSubCommandMap, + ignoreCase: Boolean, +): CmdSubCommand { + val commandName = + args.firstOrNull()?.handle(ignoreCase) ?: throw ArgumentParserException.commandRequired() + + if (!matchSubCommand(commandName)) { + throw ArgumentParserException.invalidCommand(commandName) + } + + return parameters[commandName] ?: throw ArgumentParserException.unknownCommand(commandName) +} + +private fun parseParameters( + args: List, + option: CmdSubCommand, + ignoreCase: Boolean, +): 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.handle(ignoreCase) + + val param = option.params[key] ?: throw ArgumentParserException.unknownOption(key) + + val (value, consumedExtra) = resolveParameterValue(args, index, key, inlineValue, param) + param.type.convertAndSet(value, option.obj, param.prop) + + index += 1 + consumedExtra + } + return emptyList() +} + +private fun resolveParameterValue( + args: List, + currentIndex: Int, + key: String, + inlineValue: String, + param: CmdParameter, +): 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 String.handle(ignoreCase: Boolean): String { + return if (ignoreCase) lowercase() else this +} + +private fun matchSubCommand(cmd: String) = + NAME_PATTERN.matches(cmd) + +private fun matchArgument(arg: String): Pair? = + ARGUMENT_PATTERN.matchEntire(arg)?.groupValues?.let { Pair(it[1] + it[2], it[3]) } + +// show help + +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, + allParameters: CmdSubCommandMap, + programDescription: String, +): Nothing { + showParameters(allParameters) + + e.message?.let { + println() + println(ArgumentParserException.ERROR_MSG.format(it)) + println() + } + exitProcess(0) +} + +private fun showParameters(parameters: CmdSubCommandMap) { + parameters.forEach { (name, param) -> + if (name == param.option) { + println(" - ${name} -> Option = ${param.option}, desc = ${param.description}") + param.params.forEach { (pName, pParam) -> + if (pName == pParam.long) { + println(" * ${pName} -> Param = ${pParam.long}, short = ${pParam.short}, desc = ${pParam.description}, mandatory = ${pParam.isMandatory}, type = ${pParam.type}") + } + } + } + } + +} + +private fun showVersion(): Nothing { + // TODO woher Version? + println("v1.0.0") + exitProcess(0) +} diff --git a/src/main/kotlin/net/keinekekse/args/arguments.kt b/src/main/kotlin/net/keinekekse/args/v1/arguments.kt similarity index 99% rename from src/main/kotlin/net/keinekekse/args/arguments.kt rename to src/main/kotlin/net/keinekekse/args/v1/arguments.kt index 44481b4..dca3956 100644 --- a/src/main/kotlin/net/keinekekse/args/arguments.kt +++ b/src/main/kotlin/net/keinekekse/args/v1/arguments.kt @@ -1,7 +1,7 @@ -package net.keinekekse.args +package net.keinekekse.args.v1 -import net.keinekekse.args.AbstractCommandLineParser.Companion.NAME_PATTERN -import net.keinekekse.args.AbstractCommandLineParser.Companion.SHORT_NAME_PATTERN +import net.keinekekse.args.v1.AbstractCommandLineParser.Companion.NAME_PATTERN +import net.keinekekse.args.v1.AbstractCommandLineParser.Companion.SHORT_NAME_PATTERN import net.keinekekse.util.ValueDelegate import kotlin.system.exitProcess