Umstellung auf Kotlin Multiplatform

This commit is contained in:
Hartmut Bauer committed 2026-01-18 17:17:53 +01:00
1 parent 1ee8dd356f
commit 4a73301eb3
35 files changed
+246 -47

No files matched your search

@@ -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<String> = [],
)
/**
* 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<String> = 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<String>) {
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)
@@ -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<String>) {
var allParameters: Map<String, CmdSubCommand> = 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<out SubCommand, *>,
)
object STRING : ParameterType {
override fun convertAndSet(
value: String,
obj: SubCommand,
prop: KMutableProperty1<out SubCommand, *>,
) {
prop.setter.call(obj, value)
}
}
object LONG : ParameterType {
override fun convertAndSet(
value: String,
obj: SubCommand,
prop: KMutableProperty1<out SubCommand, *>,
) {
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<out SubCommand, *>,
) {
val valueBool = value.toBoolean()
prop.setter.call(obj, valueBool)
}
}
}
private typealias CmdParameterMap = Map<String, CmdParameter>
private typealias CmdSubCommandMap = Map<String, CmdSubCommand>
private data class CmdSubCommand(
val option: String = GLOBAL_PARAMETERS,
val description: String = "",
val aliases: List<String> = 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<out SubCommand, *>,
)
// handle annotations
private fun Parameters.fetchAllParameters(): CmdSubCommandMap {
verifyParametersExist()
val globalParams = getParameters(ignoreCase)
return mutableMapOf<String, CmdSubCommand>().apply {
put(GLOBAL_PARAMETERS, CmdSubCommand(obj = this@fetchAllParameters, params = globalParams))
getSubCommands(this, ignoreCase)
}
}
private fun SubCommand.getParameters(ignoreCase: Boolean): CmdParameterMap {
val params = mutableMapOf<String, CmdParameter>()
this::class.memberProperties
.filter { it.hasAnnotation<CmdLineArgument>() }
.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<out SubCommand, *>.toCmdParameter(
instance: SubCommand,
ignoreCase: Boolean,
): CmdParameter {
val annotation = findAnnotation<CmdLineArgument>()!!
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<out SubCommand, *>.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<out SubCommand, *>): KMutableProperty1<out SubCommand, *> {
// 1. mutable
val mutableProp = property as? KMutableProperty1<out SubCommand, *>
?: 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<String, CmdSubCommand>,
ignoreCase: Boolean,
) {
val subCommandClasses =
this::class.nestedClasses.filter { it.hasAnnotation<CmdLineSubCommand>() }
subCommandClasses.forEach { kClass ->
val annotation = kClass.findAnnotation<CmdLineSubCommand>()!!
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<CmdLineArgument>() }
val hasSubcommand = me.nestedClasses.any { it.hasAnnotation<CmdLineSubCommand>() }
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<String>,
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<String>,
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<String>,
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<String>,
option: CmdSubCommand,
ignoreCase: Boolean,
): List<String> {
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<String>,
currentIndex: Int,
key: String,
inlineValue: String,
param: CmdParameter,
): Pair<String, Int> {
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<String, String>? =
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)
}
@@ -0,0 +1,7 @@
package net.keinekekse.args
import kotlin.system.exitProcess
actual fun exitApplication(rc: Int): Nothing {
exitProcess(rc)
}
@@ -0,0 +1,669 @@
package net.keinekekse.args.v1
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
class ArgumentParserException(message: String) : RuntimeException(message) {
companion object {
const val ERROR_MSG = "\n*** ERROR parsing arguments: %s\n"
fun optionExists(name: String) = ArgumentParserException("Option '$name' already exists.")
fun commandExists(name: String) =
ArgumentParserException("Command or alias '$name' already exists.")
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 optionRequired(name: String) = ArgumentParserException("Option '$name' is required.")
fun commandRequired() = ArgumentParserException("At least on command is required.")
fun invalidLongValue(value: String) =
ArgumentParserException("Value '$value' is not a long integer.")
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("Options can't be added after parsing.")
fun noMoreParsing() = ArgumentParserException("Arguments have already been parsed.")
}
}
class ParameterValue : ValueDelegate<List<String>> {
override var value: List<String>? = null
}
class OptionValue<T> : ValueDelegate<T> {
override var value: T? = null
fun isEmpty() = value == null
}
data class OptionConfig(
val name: String,
val shortName: Char? = null,
val description: String,
val required: Boolean = false,
)
interface OptionConverter<T> {
fun convert(value: String): T
}
sealed class OptionType<T>(
val converter: OptionConverter<T>,
val hasParameter: Boolean = true,
) {
companion object {
private class StringConverter : OptionConverter<String> {
override fun convert(value: String) = value
}
private class LongConverter : OptionConverter<Long> {
override fun convert(value: String) = value.toLongOrNull()
?: throw ArgumentParserException.invalidLongValue(value)
}
private class BooleanConverter : OptionConverter<Boolean> {
override fun convert(value: String) = value == "true"
}
}
open fun createOption(config: OptionConfig): Option<T> = Option(config, this)
fun convertAndSet(value: String, opt: Option<T>) {
opt.setValue(converter.convert(value))
}
object STRING : OptionType<String>(StringConverter())
object LONG : OptionType<Long>(LongConverter())
object BOOLEAN : OptionType<Boolean>(BooleanConverter(), hasParameter = false) {
override fun createOption(config: OptionConfig): Option<Boolean> {
return super.createOption(config).apply { setValue(false) }
}
}
}
data class Option<T>(
val config: OptionConfig,
val type: OptionType<T>,
) {
val optionValue = OptionValue<T>()
fun setValue(value: T) {
optionValue.value = value
}
fun isEmpty() = optionValue.isEmpty()
}
interface AddOptions {
val options: List<Option<Any>>
fun stringOption(config: OptionConfig): OptionValue<String>
fun longOption(config: OptionConfig): OptionValue<Long>
fun boolOption(config: OptionConfig): OptionValue<Boolean>
fun <T> createOption(config: OptionConfig, type: OptionType<T>): OptionValue<T>
}
/**
* Represents a command in a command-line interface that can have options and aliases.
*
* A `Command` is identified by its name and can optionally have a description and alternative names (aliases).
* It also supports adding and managing options that customize its behavior.
*
* @property name The unique name of the command.
* @property description A brief description explaining the purpose of the command.
* @property aliases A list of alternative names (aliases) that can also invoke the command.
*
* @constructor Creates a new `Command` instance with the specified name, description, and optional aliases.
*/
class Command(
val name: String,
val description: String,
val aliases: List<String> = emptyList<String>(),
) : AddOptions {
override val options = mutableListOf<Option<Any>>()
var isSet = false
internal set
/**
* Adds a string-based option to the command configuration.
*
* This method creates a command-line option that accepts a string value, based on the provided
* configuration. The configuration includes details such as the option's name, description,
* and whether it is required.
*
* @param config the configuration object containing details about the string option to be created
* @return an `OptionValue` instance representing the value of the string option
*/
override fun stringOption(config: OptionConfig): OptionValue<String> =
createOption(config, OptionType.STRING)
/**
* Creates a command-line option that accepts a `Long` value based on the provided configuration.
*
* This method utilizes the specified configuration to define details such as the option's name,
* description, and whether it is required. It then generates an option that expects a `Long` value.
*
* @param config the configuration object containing details about the long option to be created
* @return an `OptionValue<Long>` instance representing the value of the long option
*/
override fun longOption(config: OptionConfig): OptionValue<Long> =
createOption(config, OptionType.LONG)
/**
* Creates a command-line option that accepts a Boolean value based on the provided configuration.
*
* This method uses the specified configuration to define details such as the option's name,
* description, and whether it is required. The resulting option accepts a Boolean value, with
* `false` being the default if the option is not explicitly provided in the command-line arguments.
*
* @param config the configuration object containing details about the Boolean option to be created
* @return an `OptionValue<Boolean>` instance representing the value of the Boolean option
*/
override fun boolOption(config: OptionConfig): OptionValue<Boolean> =
createOption(
config.copy(required = false),
OptionType.BOOLEAN
)
override fun <T> createOption(
config: OptionConfig,
type: OptionType<T>,
): OptionValue<T> {
DefaultOptionValidator.validateOption(config, options)
@Suppress("UNCHECKED_CAST")
return type.createOption(config).also { options.add(it as Option<Any>) }.optionValue
}
override operator fun equals(other: Any?): Boolean {
if (other is String) {
return name == other || aliases.contains(other)
}
return super.equals(other)
}
override fun hashCode(): Int {
var result = isSet.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + description.hashCode()
result = 31 * result + aliases.hashCode()
result = 31 * result + options.hashCode()
return result
}
}
class CommandList(commands: MutableList<Command> = mutableListOf<Command>()) :
MutableList<Command> by commands {
operator fun contains(name: String) = this.any { it.equals(name) }
operator fun get(name: String) =
this.find { it.equals(name) } ?: throw ArgumentParserException.unknownCommand(name)
}
/**
* Parses and handles command-line arguments for commands and their associated options.
*
* The `CommandCommandLineParser` is a specialized parser designed to handle top-level commands,
* each potentially with its own set of options and arguments. It extends `AbstractCommandLineParser`
* to incorporate functionality for parsing commands alongside their arguments and options.
*
* Example:
* ```
* val arguments = CommandCommandLineParser("some info text")
*
* val cmdImport = arguments.addCommand("import", "description", listOf("imp", "in"))
* val auto by cmdImport.boolOption(OptionConfig("auto",'a',"description",false))
*
* // more option & commands ...
*
* val files by arguments.parametersList()
* arguments.parse(args)
*
* when {
* cmdImport.isSet -> {
*
* ```
*
* @constructor accepts a help string that is used to display usage instructions, adding context
* for the users of the command-line tool.
* @param helpString A message string to display as guidance for the tool usage.
*/
class CommandCommandLineParser(private val helpString: String) :
AbstractCommandLineParser(helpString) {
private val commands = CommandList()
/**
* Holds the currently active command after the command-line arguments are parsed.
*
* This variable is updated when a valid command is identified and processed by the parser.
* If no valid command is found during parsing, it remains null.
* This property is read-only externally and can only be set internally by the parser.
*/
var command: Command? = null
private set
override val helpFormatter = CommandHelpFormatter(commands, helpString)
init {
addCommand("help", "Display this help message.")
}
/**
* Adds a new command with the specified name, description, and optional aliases.
*
* This method registers a command to the parser, allowing it to be identified by its name or aliases.
* Throws an exception if commands cannot be added or if the name or aliases conflict with existing commands.
*
* @param name The unique name of the command to be added.
* @param description A brief description of the command.
* @param aliases An optional list of alternative names (aliases) for the command. Defaults to an empty list.
* @return A `Command` instance that can be used to add option for this command.
*/
fun addCommand(name: String, description: String, aliases: List<String> = emptyList()) =
try {
if (isParsed) throw ArgumentParserException.noMoreOptions()
if (commands.contains(name) || aliases.any { commands.contains(it) })
throw ArgumentParserException.commandExists(name)
Command(name, description, aliases).also { commands.add(it) }
} catch (e: ArgumentParserException) {
handleParsingError(e)
}
/**
* Parses an array of command-line arguments and validates the command and its options.
*
* The method processes the first argument as the command and checks whether it is valid.
* If the command is recognized and exists within the predefined set of allowed commands,
* its associated options are then parsed. If an error occurs during parsing, appropriate
* error handling is invoked.
*
* @param args The array of command-line arguments to be parsed. The first element is treated
* as the primary command, and the rest are interpreted as its options.
*/
override fun parse(args: Array<String>) {
if (args.isEmpty()) {
handleParsingError(ArgumentParserException.commandRequired())
}
val cmd = args[0]
if (!argumentParser.parseCommand(cmd)) {
handleParsingError(ArgumentParserException.invalidCommand(cmd))
}
if (cmd !in commands) {
handleParsingError(ArgumentParserException.unknownCommand(cmd))
} else {
parseArguments(args.drop(1).toTypedArray(), commands[cmd].options)
commands[cmd].isSet = true
command = commands[cmd]
}
}
}
/**
* A command-line parser specifically designed to parse and manage options provided
* via command-line arguments. It extends the `AbstractCommandLineParser` and implements
* the `AddOptions` interface.
*
* This parser supports different types of options including string, long, and boolean,
* and ensures validation during the parsing process. It also auto-registers a help option
* to display the usage of the parser.
*
* Example:
* ```
* val arguments = OptionCommandLineParser("some info text")
* val author by arguments.stringOption(OptionConfig("author", 'a', "some author", true))
*
* // more options ...
*
* val files by arguments.parametersList()
* arguments.parse(args)
*
* if (author != null) {
*```
*
* @constructor Creates an `OptionCommandLineParser` with a custom help string.
* @param helpString Descriptive text that is displayed when the help option is invoked.
*/
class OptionCommandLineParser(private val helpString: String) :
AbstractCommandLineParser(helpString), AddOptions {
override val options = mutableListOf<Option<Any>>()
override val helpFormatter = OptionHelpFormatter(options, helpString)
init {
boolOption(OptionConfig("help", 'h', "Display this help message"))
}
/**
* Creates and registers a string-based command-line option based on the provided configuration.
*
* @param config The configuration object containing the name, short name, description,
* and requirements for the string option.
* @return An instance of OptionValue that delegates access to the parsed value of the option.
*/
override fun stringOption(config: OptionConfig): OptionValue<String> =
createOption(config, OptionType.STRING)
/**
* Creates and registers a command-line option that parses and returns a `Long` value.
*
* @param config The configuration object containing the name, short name, description,
* and requirements for the long option.
* @return An instance of `OptionValue<Long>` that provides access to the parsed value
* of the long option.
*/
override fun longOption(config: OptionConfig): OptionValue<Long> =
createOption(config, OptionType.LONG)
/**
* Creates and registers a command-line option that parses and returns a `Boolean` value.
*
* @param config The configuration object containing the name, short name, description,
* and requirements for the boolean option.
* @return An instance of `OptionValue<Boolean>` that provides access to the parsed value
* of the boolean option.
*/
override fun boolOption(config: OptionConfig): OptionValue<Boolean> =
createOption(
config.copy(required = false),
OptionType.BOOLEAN
)
override fun <T> createOption(
config: OptionConfig,
type: OptionType<T>,
): OptionValue<T> {
try {
if (isParsed) throw ArgumentParserException.noMoreOptions()
DefaultOptionValidator.validateOption(config, options)
@Suppress("UNCHECKED_CAST")
return type.createOption(config).also { options.add(it as Option<Any>) }.optionValue
} catch (e: ArgumentParserException) {
handleParsingError(e)
}
}
/**
* Parses the provided array of command-line arguments.
*
* This method processes the input arguments, identifies the options and parameters
* based on the argument parser's configuration, and validates the required options.
* If the arguments have already been parsed, it raises an exception.
* In case of errors during parsing or validation, an error message is displayed
* and the help information is shown.
*
* @param args The array of command-line arguments to be parsed.
*/
override fun parse(args: Array<String>) {
parseArguments(args, options)
}
}
abstract class AbstractCommandLineParser(private val helpString: String) {
private val parameters = ParameterValue()
protected var isParsed = false
protected val argumentParser: ArgumentParser = DefaultArgumentParser()
protected abstract val helpFormatter: HelpFormatter
companion object {
const val NAME = """[a-zA-Z][\w-]+"""
const val SHORT_NAME = """[a-zA-Z]"""
val ARGUMENT_PATTERN = """^(?:-(${SHORT_NAME})|--(${NAME}))(?:=(.+)|$)$""".toRegex()
val NAME_PATTERN = """^${NAME}$""".toRegex()
val SHORT_NAME_PATTERN = """^${SHORT_NAME}$""".toRegex()
}
/**
* Returns the list of parameters provided to the argument parser.
*
* The parameters are parsed from the command-line input following the provided argument options.
* This method allows accessing any additional parameters that are not directly associated with
* the defined options.
*
* @return A list of strings representing the positional parameters passed after all options.
*/
fun parametersList() = parameters
abstract fun parse(args: Array<String>)
protected fun parseArguments(args: Array<String>, options: MutableList<Option<Any>>) {
if (isParsed) throw ArgumentParserException.noMoreParsing()
try {
argumentParser.parseArguments(args, options, parameters)
isParsed = true
DefaultOptionValidator.validateRequiredOptions(options)
handleHelpOption(options)
} catch (e: ArgumentParserException) {
handleParsingError(e)
}
}
fun showHelp(errorMessage: String = ""): Nothing {
helpFormatter.show()
if (errorMessage.isNotEmpty()) {
println()
println(errorMessage)
println()
}
exitProcess(0)
}
private fun handleHelpOption(options: MutableList<Option<Any>>) {
if (findOption("help", options)?.optionValue?.value == true) {
showHelp()
}
}
private fun findOption(name: String, options: MutableList<Option<Any>>): Option<Any>? =
if (name.length > 1) options.find { it.config.name == name }
else options.find { it.config.shortName == name[0] }
protected fun handleParsingError(e: ArgumentParserException): Nothing {
showHelp(ArgumentParserException.ERROR_MSG.format(e.message))
}
// argument parsing
interface ArgumentParser {
fun parseArguments(
args: Array<String>,
options: List<Option<Any>>,
parameters: ParameterValue,
)
fun parseCommand(cmd: String): Boolean
}
private class DefaultArgumentParser : ArgumentParser {
override fun parseArguments(
args: Array<String>,
options: List<Option<Any>>,
parameters: ParameterValue,
) {
var index = 0
parameters.value = emptyList()
while (index < args.size) {
val parseResult = parseArgument(args[index])
if (parseResult != null) {
val (key, value) = parseResult
index += handleOption(key, value, args, index, options)
index++
} else {
parameters.value = args.drop(index)
break
}
}
}
override fun parseCommand(cmd: String) =
NAME_PATTERN.matches(cmd)
private fun parseArgument(arg: String): Pair<String, String>? =
ARGUMENT_PATTERN.matchEntire(arg)?.groupValues?.let { Pair(it[1] + it[2], it[3]) }
private fun handleOption(
key: String,
value: String,
args: Array<String>,
index: Int,
options: List<Option<Any>>,
): Int {
val option = options.find {
it.config.name == key || it.config.shortName?.toString() == key
} ?: throw ArgumentParserException.unknownOption(key)
return when {
option.type.hasParameter -> handleParameterOption(option, value, args, index)
else -> handleFlagOption(option, value)
}
}
private fun handleParameterOption(
option: Option<Any>,
value: String,
args: Array<String>,
index: Int,
): Int = when {
value.isNotBlank() -> {
option.type.convertAndSet(value, option)
0
}
index < args.size - 1 && !ARGUMENT_PATTERN.matches(args[index + 1]) -> {
option.type.convertAndSet(args[index + 1], option)
1
}
else -> throw ArgumentParserException.optionRequiresValue(option.config.name)
}
private fun handleFlagOption(option: Option<Any>, value: String): Int {
if (value.isNotBlank()) {
throw ArgumentParserException.optionNoValue(option.config.name)
}
option.type.convertAndSet("true", option)
return 0
}
}
// help output
protected interface HelpFormatter {
fun show(indent: String = "")
}
protected class CommandHelpFormatter(
protected val commands: CommandList,
private val infoMessage: String,
) : HelpFormatter {
override fun show(indent: String) {
println()
println(infoMessage)
for (cmd in commands) {
val aliases = buildString {
if (cmd.aliases.isNotEmpty()) append(
"|'${
cmd.aliases.joinToString("'|'")
}'"
)
}
OptionHelpFormatter(
cmd.options,
"Command '${cmd.name}'${aliases}: ${cmd.description}"
).show(" ")
println()
}
}
}
protected class OptionHelpFormatter(
private val options: List<Option<Any>>,
private val infoMessage: String,
) : HelpFormatter {
override fun show(indent: String) {
println(infoMessage)
if (options.isNotEmpty()) {
if (indent.isEmpty()) println("Options:")
printOptions(indent)
}
}
private fun printOptions(indent: String) {
val maxNameLength = calculateMaxNameLength() + 4 + indent.length
options.forEach { printOption(it, maxNameLength, indent) }
}
private fun calculateMaxNameLength(): Int =
options.maxOfOrNull { opt ->
opt.config.name.length + 2 + (opt.config.shortName?.let { 4 } ?: 0)
} ?: 0
private fun printOption(option: Option<Any>, maxNameLength: Int, indent: String) {
val shortName = option.config.shortName?.let { "-$it, " } ?: ""
val optionText = StringBuilder()
.append(indent)
.append(shortName)
.append("--")
.append(option.config.name)
.padEnd(maxNameLength)
println("$optionText ${option.config.description}")
}
}
}
// option validation
private interface OptionValidator {
fun validateOption(config: OptionConfig, existingOptions: List<Option<Any>>)
fun validateRequiredOptions(options: List<Option<Any>>)
}
private object DefaultOptionValidator : OptionValidator {
override fun validateOption(config: OptionConfig, existingOptions: List<Option<Any>>) {
validateOptionName(config.name)
validateShortName(config.shortName)
validateOptionUniqueness(config, existingOptions)
}
override fun validateRequiredOptions(options: List<Option<Any>>) {
val emptyNames = options.filter { it.config.required && it.isEmpty() }
.joinToString(", ") { it.config.name }
if (emptyNames.isNotEmpty()) throw ArgumentParserException.optionRequired(emptyNames)
}
private fun validateOptionName(name: String) {
if (!name.matches(NAME_PATTERN)) {
throw ArgumentParserException.invalidName(name)
}
}
private fun validateShortName(shortName: Char?) {
if (shortName != null && !shortName.toString().matches(SHORT_NAME_PATTERN)) {
throw ArgumentParserException.invalidShortName(shortName.toString())
}
}
private fun validateOptionUniqueness(
config: OptionConfig,
existingOptions: List<Option<Any>>,
) {
val nameExists = existingOptions.any { it.config.name == config.name }
val shortNameExists = config.shortName?.let { shortName ->
existingOptions.any { it.config.shortName == shortName }
} ?: false
if (nameExists || shortNameExists) {
throw ArgumentParserException.optionExists(config.name)
}
}
}
@@ -0,0 +1,56 @@
package net.keinekekse.config
import net.keinekekse.util.VERSION
import net.keinekekse.version.Version
import java.io.File
/**
* Abstract base class for initialization configuration.
* Provides version information and configuration file management.
*
* @property configFileName Name of the configuration file to use
*/
abstract class AbstractInitConfig(
val applicationVersion: Version,
val configFileName: String = DEFAULT_CONFIG_FILENAME,
) {
// abstract fun start()
/** Current application version */
// val applicationVersion: Version by lazy { Version(this::class.java) }
/** Library version information */
val libraryVersion: Version = VERSION //by lazy { Version(Version::class.java) }
/** Configuration directory path */
val configDirectory: String by lazy {
buildPath(
System.getProperty(USER_HOME_PROPERTY),
CONFIG_DIR,
applicationVersion.name
)
}
/** Current working directory */
val workingDirectory: String by lazy {
System.getProperty(USER_DIR_PROPERTY) + File.separator
}
/** User-specific configuration */
val userConfig by lazy {
UserConfigProperties(buildPath(configDirectory, configFileName))
}
/** System-wide configuration */
val systemConfig = SystemConfigProperties
private fun buildPath(vararg parts: String): String =
parts.joinToString(File.separator)
companion object {
private const val DEFAULT_CONFIG_FILENAME = "config.properties"
private const val CONFIG_DIR = ".config"
private const val USER_HOME_PROPERTY = "user.home"
private const val USER_DIR_PROPERTY = "user.dir"
}
}
@@ -0,0 +1,41 @@
package net.keinekekse.config
import java.io.InputStream
import java.util.*
private const val CONFIG_FILE_NAME = "config.properties"
private const val ERROR_LOADING_CONFIG = "Could not load configuration from $CONFIG_FILE_NAME"
// TODO mit WAR testen wg. Pfad zu config.properties
/**
* Projekt-Properties
*
* Die Datei ```config.properties``` wird aus dem JAR-root geladen.
* (-> in ```main/resources``` anlegen)
*
* ```
* val prop: String = ConfigProperties["some.prop"] ?: "default"
*
* ```
*/
object SystemConfigProperties {
private val properties = Properties()
operator fun get(key: String): String? = properties.getProperty(key)
init {
loadConfigFile()
}
private fun loadConfigFile() {
getConfigStream().use { stream ->
properties.load(stream)
}
}
private fun getConfigStream(): InputStream {
return javaClass.classLoader.getResourceAsStream(CONFIG_FILE_NAME)
?: throw IllegalStateException(ERROR_LOADING_CONFIG)
}
}
@@ -0,0 +1,144 @@
package net.keinekekse.config
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileNotFoundException
import java.util.*
/**
* Manages user-specific configuration properties from a file.
* Creates the configuration file if it doesn't exist.
*
* @property configPath Path to the configuration file
* @throws FileNotFoundException if the configuration file cannot be created or accessed
*/
class UserConfigProperties(private val configPath: String) {
private companion object {
const val ERROR_CREATE_CONFIG = "Failed to create configuration file"
const val ERROR_LOAD_CONFIG = "Failed to load configuration from file"
}
private val properties = Properties()
init {
loadProperties()
}
/**
* Returns the value associated with the given key from the properties.
*
* @param key The property key
* @return The property value or null if the key doesn't exist
*/
operator fun get(key: String): String? = properties.getProperty(key)
private fun loadProperties() {
ensureConfigFileExists()
try {
File(configPath).inputStream().use { stream ->
properties.load(stream)
}
} catch (e: Exception) {
throw FileNotFoundException("$ERROR_LOAD_CONFIG: ${e.message}")
}
}
private fun ensureConfigFileExists() {
val configFile = File(configPath)
if (!configFile.exists()) {
try {
configFile.parentFile?.mkdirs()
if (!configFile.createNewFile()) {
throw FileNotFoundException(ERROR_CREATE_CONFIG)
}
} catch (e: Exception) {
throw FileNotFoundException("$ERROR_CREATE_CONFIG: ${e.message}")
}
}
}
}
/**
* Handles JSON file reading and parsing with a relaxed configuration.
* Supports comments, case-insensitive enum parsing, and ignores unknown keys.
*
* ```
* val obj: T = JsonConfig.parseFromFile<T>("config.json")
* // or
* val json = JsonConfig.read("config.json")
* val obj: T = JsonConfig.parse<T>(json)
* ```
*/
object JsonConfig {
private const val FILE_NOT_FOUND = "File not found: "
private const val FILE_READ_ERROR = "Failed to read file: "
/**
* JSON parser configuration optimized for flexibility and ease of use.
*/
@OptIn(ExperimentalSerializationApi::class)
val jsonConfiguration = Json {
ignoreUnknownKeys = true
allowComments = true
decodeEnumsCaseInsensitive = true
}
/**
* Custom exception for JSON-related operations
*/
class JsonConfigException(message: String, cause: Throwable? = null) :
RuntimeException(message, cause)
/**
* Reads content from a JSON file.
* @param filePath Path to the JSON file
* @return String content of the file
* @throws JsonConfigException if the file doesn't exist or cannot be read
*/
fun read(filePath: String): String {
val jsonFile = File(filePath)
return when {
!jsonFile.exists() -> throw JsonConfigException(FILE_NOT_FOUND + filePath)
else -> try {
jsonFile.readText()
} catch (e: Exception) {
throw JsonConfigException(FILE_READ_ERROR + filePath, e)
}
}
}
/* TODO exceptions hier fangen?
JSON Exceptions:
malformed, falscher Datentyp: JsonDecodingException
missing: MissingFieldException
unbekannter Name für enum: SerializationException
wenn ein key mehrmals vorkommt, wird nur das letzte gelesen (kein Fehler!)
*/
/**
* Parses a JSON string into the specified type.
* @param json JSON string to parse
* @return Parsed object of type T
*/
inline fun <reified T> parse(json: String): T =
jsonConfiguration.decodeFromString<T>(json)
/**
* Parses a JSON file located at the given path into an object of the specified type.
*
* @param T The type of the object to parse.
* @param filePath The path to the JSON file to parse.
* @return An object of type T parsed from the JSON file.
* @throws JsonConfigException If the file doesn't exist, can't be read,
* or if there's an error during parsing.
*/
inline fun <reified T> parseFromFile(filePath: String): T {
val json = read(filePath)
return parse(json)
}
}
@@ -0,0 +1,96 @@
package net.keinekekse.exec
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
class CommandExecutionException(message: String) : Exception(message)
/**
* Runs program in list [arguments] and outputs results to [stdout], [stderr] and [rc].
*
* A return code of >=0x10000 in [rc] signals an exception with the stack trace
* in [stderr].
*
* ```
* val runResult = ExecutionResult(listOf("ebook-meta", "/path/to/epub", "--title", "New Title"))
* println("RC = ${runResult.rc}")
* ```
* @param arguments program and arguments to run as [List]
* @param throwAtError throw exception at `rc != 0` (defaults to true)
* @param workingDir working directory for the program (defaults to ".")
* @param timeoutSec timeout in seconds (defaults to 60)
*
* @property stdout program output to STDOUT
* @property stderr program output to STDERR
* @property rc return code
*
* @throws CommandExecutionException if [throwAtError] is true and [rc] is not 0
*/
class ExecutionResult(
val arguments: List<String>,
val throwAtError: Boolean = true,
val workingDir: String = ".",
val timeoutSec: Long = 60,
) {
val stdout: String
val stderr: String
val rc: Int
companion object {
private const val IO_EXCEPTION_CODE = 0x10000
private const val INTERRUPTED_EXCEPTION_CODE = 0x10001
private const val GENERIC_EXCEPTION_CODE = 0x10002
}
init {
val executionResult = try {
executeProcess()
} catch (e: Exception) {
handleProcessException(e)
}
stdout = executionResult.first
stderr = executionResult.second
rc = executionResult.third
if (rc != 0) {
logExecutionError()
if (throwAtError) {
throw CommandExecutionException("Error executing ${arguments}\nRC = $rc")
}
}
}
private fun executeProcess(): Triple<String, String, Int> {
return ProcessBuilder(arguments)
.directory(File(workingDir))
.start()
.run {
waitFor(timeoutSec, TimeUnit.SECONDS)
Triple(
inputStream.bufferedReader().use { it.readText() },
errorStream.bufferedReader().use { it.readText() },
exitValue()
)
}
}
private fun handleProcessException(exception: Exception): Triple<String, String, Int> {
val errorCode = when (exception) {
is IOException -> IO_EXCEPTION_CODE
is InterruptedException -> INTERRUPTED_EXCEPTION_CODE
else -> GENERIC_EXCEPTION_CODE
}
val errorMessage =
"${exception::class.simpleName}: ${exception.message}\n${exception.stackTraceToString()}"
return Triple("", errorMessage, errorCode)
}
private fun logExecutionError() {
println("\n*** ERROR executing $arguments")
println("RC = $rc")
println("STDOUT:\n$stdout")
println("STDERR:\n$stderr")
println("***")
}
}
@@ -0,0 +1,27 @@
package net.keinekekse.util
/**
* Checks if a network connection to the specified host and port is possible within the given timeout.
*
* @param host the host to connect to
* @param port the port to connect to on the host
* @param timeoutMs the timeout in milliseconds for the connection attempt, default is 1000ms
* @return true if the connection is successful, false otherwise
*/
fun isConnectionPossible(host: String, port: Int, timeoutMs: Int = 1000): Boolean {
return try {
java.net.Socket().use { socket ->
socket.connect(java.net.InetSocketAddress(host, port), timeoutMs)
true
}
} catch (e: Exception) {
when (e) {
is java.net.ConnectException,
is java.net.SocketTimeoutException,
is java.net.UnknownHostException,
-> false
else -> throw e
}
}
}
@@ -0,0 +1,228 @@
package net.keinekekse.version.manifest
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
/**
* Version-Info für .jar oder .war
*
*
* Die Versions-Informationen werden aus der Datei META-INF/MANIFEST.MF gelesen.
*
* Fürs Setzen der Werte in der MANIFEST.MF ist das Gradle version-plugin verantwortlich.
*
* Das Version-Plugin setzt folgende Werte
* ```
* Implementation-Title = name;displayName;description
* Implementation-Vendor = copyright
* Implementation-Version = version;buildNumber;Instant.now()
* ```
* Dadurch könne die Werte über die Standardfunktionen, wie z.B. [Package.implementationTitle],
* geholt werden. Das erübrigt eigene Routinen für das Finden der MANIFEST.MF, auf die per
* [Class.getPackage] zugegriffen werden kann.
*
* @param clazz Klasse aus der jeweiligen .jar oder .war
*
* ```
* val version = Version(this::class.java)
* val libraryVersion = Version(Version::class.java)
* ```
*/
class Version(clazz: Class<*>) {
val versionObject: VersionNumber
private val attributes: MutableMap<String, String>
init {
val manifest = determineManifestPath(clazz)
attributes = loadManifestAttributes(manifest)
versionObject = initializeVersionNumbers()
}
private fun determineManifestPath(clazz: Class<*>): Package? {
return clazz.`package`
// val classResourcePath = clazz.getResource("${clazz.simpleName}.class")?.toString() ?: ""
// val baseFolder = if (classResourcePath.contains(WEB_INF_PATH)) {
// classResourcePath.substringBefore(WEB_INF_PATH) + MANIFEST_FILE_PATH
// } else {
// classResourcePath.substringBefore(
// clazz.name.replace(
// '.',
// File.separatorChar
// ) + CLASS_EXTENSION
// ) + MANIFEST_FILE_PATH
// }
// return baseFolder
}
private fun loadManifestAttributes(manifest: Package?): MutableMap<String, String> {
val attributes = ConcurrentHashMap<String, String>()
val implTitle = (manifest?.implementationTitle ?: "").split(";")
attributes.put(PROP_NAME, implTitle.getOrNull(0) ?: "")
attributes.put(PROP_DISPLAYNAME, implTitle.getOrNull(1) ?: "")
attributes.put(PROP_DESCRIPTION, implTitle.getOrNull(2) ?: "")
val version = (manifest?.implementationVersion ?: "").split(";")
attributes.put(PROP_VERSION, version.getOrNull(0) ?: "")
attributes.put(PROP_BUILD_NUMBER, version.getOrNull(1) ?: "")
attributes.put(PROP_BUILD_TIMESTAMP, version.getOrNull(2) ?: "")
attributes.put(PROP_COPYRIGHT, manifest?.implementationVendor ?: "")
// try {
// URI(manifestPath).toURL().openStream().use {
// val manifest = Manifest(it)
// manifest.mainAttributes.forEach { key, value ->
// attributes[key.toString()] = value.toString()
// }
// }
// } catch (e: IOException) {
// throw RuntimeException("Failed to read MANIFEST.MF at $manifestPath", e)
// }
return attributes
}
private fun initializeVersionNumbers(): VersionNumber =
VersionNumber(
attributes[PROP_VERSION] ?: "",
attributes[PROP_BUILD_TIMESTAMP] ?: "",
attributes[PROP_BUILD_NUMBER] ?: ""
)
val infoString: String
get() = "$displayname $fullVersion $copyright"
val displayname: String
get() = attributes[PROP_DISPLAYNAME] ?: ""
val copyright: String
get() = attributes[PROP_COPYRIGHT] ?: ""
val name: String
get() = attributes[PROP_NAME] ?: ""
val description: String
get() = attributes[PROP_DESCRIPTION] ?: ""
val version: String
get() = versionObject.version
val fullVersion: String
get() = versionObject.fullVersion
val major: Int
get() = versionObject.major
val minor: Int
get() = versionObject.minor
val patch: Int
get() = versionObject.patch
val identifier: String
get() = versionObject.identifier
val build: String
get() = versionObject.build
val buildTimestamp: String
get() = versionObject.buildTimestamp
companion object {
private const val PROP_NAME = "name"
private const val PROP_DISPLAYNAME = "displayname"
private const val PROP_COPYRIGHT = "copyright"
private const val PROP_DESCRIPTION = "description"
private const val PROP_VERSION = "version"
private const val PROP_BUILD_TIMESTAMP = "build-timestamp"
private const val PROP_BUILD_NUMBER = "build-number"
// private const val MANIFEST_FILE_PATH = "META-INF/MANIFEST.MF"
// private const val CLASS_EXTENSION = ".class"
// private const val WEB_INF_PATH = "WEB-INF/classes"
}
}
/**
* Versions-Nr. zerlegen<br></br>
* <br></br>
* Folgendes Format wird verstanden: [v]int[.int[.int]][-|.alpha|rc ..][-|.build-Nr. o.ä.]<br></br>
* Ergebnis:<br></br>
*
* <pre>`String.format("%d.%d.%d-%s-%s", getMajor(), getMinor(), getPatch(), getIdentifier(), getBuild());
`</pre> *
*
* <br></br>
* Außerdem wird Der Maven Build-Timestamp als String des Formats `"yyyy-MM-dd @HH:mm:ss"`
* sowie als `long mavenEpochSeconds` gespeichert. Falls die übergebene Version keinen
* Build-String enthält wird statt dessen `mavenEpochSeconds` verwendet.
*/
class VersionNumber(val version: String, timestamp: String, buildNumber: String = "") {
private companion object {
const val VERSION_REGEX = """v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-.](\w+))?(?:[-.](.*))?"""
val DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd @HH:mm:ss")
}
var major: Int = 0
private set
var minor: Int = 0
private set
var patch: Int = 0
private set
var identifier: String = "" // e.g., "alpha", etc.
private set
var build: String = "" // Build metadata, e.g., "Timestamp" or "SNAPSHOT"
private set
var buildTimestamp: String = ""
private set
var buildEpochSeconds: Long = 0
private set
init {
parseVersion(version)
initializeBuildTimestamp(timestamp)
if (buildNumber.isNotEmpty()) {
build = buildNumber
}
}
val fullVersion: String
get() = if (identifier.isEmpty()) {
"$major.$minor.$patch (build $build)"
} else {
"$major.$minor.$patch-$identifier-$build"
}
private fun parseVersion(version: String) {
val pattern = Pattern.compile(VERSION_REGEX)
val matcher = pattern.matcher(version)
if (matcher.matches()) {
major = parseVersionComponent(matcher.group(1))
minor = parseVersionComponent(matcher.group(2))
patch = parseVersionComponent(matcher.group(3))
identifier = matcher.group(4) ?: ""
build = resolveBuildMetadata(matcher.group(5) ?: "")
}
}
private fun parseVersionComponent(value: String?) = value?.toIntOrNull() ?: 0
private fun resolveBuildMetadata(extractedBuild: String): String =
if (extractedBuild.isNotEmpty()) extractedBuild else buildEpochSeconds.toString()
private fun initializeBuildTimestamp(timestamp: String) =
try {
val instant = Instant.parse(timestamp)
val date = instant.atZone(TimeZone.getDefault().toZoneId())
buildEpochSeconds = date.toEpochSecond()
buildTimestamp = date.format(DATE_TIME_FORMATTER)
} catch (e: DateTimeParseException) {
buildTimestamp = "Invalid timestamp"
}
}