diff --git a/.gitignore b/.gitignore index 1b6985c..adfa9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,19 @@ -# Ignore Gradle project-specific cache directory +*.iml +.kotlin .gradle - -# Ignore Gradle build output directory -build +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea +.DS_Store +captures +.externalNativeBuild +.cxx +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings +node_modules/ diff --git a/.idea/artifacts/util_js_0_3_0_beta_11.xml b/.idea/artifacts/util_js_0_3_0_beta_11.xml new file mode 100644 index 0000000..d84ee32 --- /dev/null +++ b/.idea/artifacts/util_js_0_3_0_beta_11.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_js_0_3_0_beta_8.xml b/.idea/artifacts/util_js_0_3_0_beta_8.xml new file mode 100644 index 0000000..113590e --- /dev/null +++ b/.idea/artifacts/util_js_0_3_0_beta_8.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_jvm_0_3_0_beta_11.xml b/.idea/artifacts/util_jvm_0_3_0_beta_11.xml new file mode 100644 index 0000000..71efc8f --- /dev/null +++ b/.idea/artifacts/util_jvm_0_3_0_beta_11.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_jvm_0_3_0_beta_8.xml b/.idea/artifacts/util_jvm_0_3_0_beta_8.xml new file mode 100644 index 0000000..987a037 --- /dev/null +++ b/.idea/artifacts/util_jvm_0_3_0_beta_8.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_wasm_js_0_3_0_beta_11.xml b/.idea/artifacts/util_wasm_js_0_3_0_beta_11.xml new file mode 100644 index 0000000..4ebd95d --- /dev/null +++ b/.idea/artifacts/util_wasm_js_0_3_0_beta_11.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_wasm_js_0_3_0_beta_8.xml b/.idea/artifacts/util_wasm_js_0_3_0_beta_8.xml new file mode 100644 index 0000000..7f73424 --- /dev/null +++ b/.idea/artifacts/util_wasm_js_0_3_0_beta_8.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_wasm_wasi_0_3_0_beta_11.xml b/.idea/artifacts/util_wasm_wasi_0_3_0_beta_11.xml new file mode 100644 index 0000000..45d90a1 --- /dev/null +++ b/.idea/artifacts/util_wasm_wasi_0_3_0_beta_11.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/util_wasm_wasi_0_3_0_beta_8.xml b/.idea/artifacts/util_wasm_wasi_0_3_0_beta_8.xml new file mode 100644 index 0000000..ccef720 --- /dev/null +++ b/.idea/artifacts/util_wasm_wasi_0_3_0_beta_8.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/buildnumber.properties b/buildnumber.properties index f8b2234..b1b6e94 100644 --- a/buildnumber.properties +++ b/buildnumber.properties @@ -1,3 +1,3 @@ #auto generated -#Mon Jan 19 01:08:01 CET 2026 -build.number=117 +#Mon Jan 19 21:39:57 CET 2026 +build.number=129 diff --git a/gradle.properties b/gradle.properties index 9832217..e594967 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,8 @@ +#Kotlin kotlin.code.style=official -org.gradle.configuration-cache=true +kotlin.daemon.jvmargs=-Xmx3072M org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +#Gradle +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 0850fa1..ff040b5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,7 @@ dependencyResolutionManagement { versionCatalogs { create("masterLibs") { // from(files("gradle/masterlibs.versions.toml")) - from("net.keinekekse:version-catalog:116") + from("net.keinekekse:version-catalog:127") } } } diff --git a/src/commonMain/kotlin/net/keinekekse/args/impl.kt b/src/commonMain/kotlin/net/keinekekse/args/impl.kt index 2f5bd38..52c27c9 100644 --- a/src/commonMain/kotlin/net/keinekekse/args/impl.kt +++ b/src/commonMain/kotlin/net/keinekekse/args/impl.kt @@ -32,13 +32,13 @@ class VerifyParameterException(message: String) : RuntimeException(message) * This function is used to signal that there was an error during argument parsing. * * For use in the application. The exception will be caught by the command-line parser - * and displayed to the user via showHelp(). + * and displayed to the user together with the help information. * * @param msg The error message to include in the exception. * @throws VerifyParameterException with the provided error message. */ fun showParsingError(msg: String): Nothing { - throw VerifyParameterException("*** ERROR parsing arguments: $msg") + throw VerifyParameterException(msg) } /** @@ -117,37 +117,44 @@ class SubCommand private constructor( 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) + val processedLong = long.handleCase(ignoreCase) + val processedShort = short?.toString()?.handleCase(ignoreCase) - @Suppress("UNCHECKED_CAST") - obj.params[long] = this as ParameterData - short?.let { - obj.params[it] = this - } + return runCatching { + type.createOption(processedLong, processedShort, description, isMandatory).apply { + verifyParameter(this) + registerParameter(this, processedLong, processedShort) }.prop - } - return result.getOrElse { showDefiningError(it.message ?: "") } + }.getOrElse { showDefiningError(it.message ?: "") } + } + + private fun registerParameter(parameter: ParameterData<*>, long: String, short: String?) { + @Suppress("UNCHECKED_CAST") + val data = parameter as ParameterData + obj.params[long] = data + short?.let { obj.params[it] = data } } 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 longName = parameter.long + if (!NAME_PATTERN.matches(longName)) { + throw ArgumentParserException.invalidName(longName) } - val n = parameter.long.lowercase() - if (n == "help" || n == "version") { + if (obj.params.containsKey(longName)) { + throw ArgumentParserException.optionExists(longName) + } + + parameter.short?.let { shortName -> + if (!SHORT_NAME_PATTERN.matches(shortName)) { + throw ArgumentParserException.invalidShortName(shortName) + } + if (obj.params.containsKey(shortName)) { + throw ArgumentParserException.optionExists(shortName) + } + } + + val lowerLong = longName.lowercase() + if (lowerLong == "help" || lowerLong == "version") { throw ArgumentParserException.noHelpOrVersion() } @@ -166,12 +173,60 @@ class SubCommand private constructor( * @param programVersion The version information of the program, including display name and copyright. * @param ignoreCase Determines whether the parsing should ignore case sensitivity for commands and parameters. * @param remainingDescription The description for remaining command-line arguments, or null if no arguments are allowed. + * @param emptyCommandLineAllowed Indicates whether the parser should allow an empty command line to be parsed. Defaults to false. + * + * ``` + * // global parameters + * + * val commandLine = CommandLine( + * programVersion = VERSION, + * remainingDescription = "EPUB-Dateien", + * ignoreCase = true, + * ) + * + * val param1 by commandLine.addStringParameter( + * long = "param", + * short = 'P', + * description = "parameter description" + * ) + * // more global parameters + * + * commandLine.setExecution { additionalParameters: List -> + * // called when no subcommand is specified + * } + * + * // subcommand + * + * val commandSubCommand = commandLine.addSubCommand( + * "command", + * "My custom command", + * listOf("cmd") // aliases + * ) + * + * val par1 by commandSubCommand.addStringParameter( + * long = "par1", + * description = "Parameter Par1 ", + * isMandatory = true + * ) + * // more parameters + * + * commandSubCommand.setExecution { additionalParameters -> + * // called when this subcommand is found in the command line + * } + * + * // more subcommands + * + * // start program + * commandLine.parseAndRun(*args) + * ``` */ class CommandLine( val programVersion: Version, val ignoreCase: Boolean = false, val remainingDescription: String? = null, // null -> no remaining allowed + val emptyCommandLineAllowed: Boolean = false, ) : AddParameter { + private var hasRun = false private val globalSubCommand = SubCommand.create(SubCommandData(), ignoreCase) private var subCommands = mutableMapOf() @@ -210,68 +265,68 @@ class CommandLine( 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) + ): SubCommand = runCatching { + val normalizedName = normalizeAndVerifySubCommand(name, ignoreCase) + val subData = SubCommandData(normalizedName, description, aliases) + subCommands[normalizedName] = subData + aliases.forEach { alias -> + val alias = normalizeAndVerifySubCommand(alias, ignoreCase) + subCommands[alias] = subData } - return result.getOrElse { showDefiningError(it.message ?: "") } - } + SubCommand.create(subData, ignoreCase) + }.getOrElse { showDefiningError(it.message ?: "") } fun parseAndRun(vararg args: String) { - val result = runCatching { + runCatching { val selectedSubCommand = parse(args.toList()) selectedSubCommand.execute(selectedSubCommand.additionalParameters) + }.onFailure { exception -> + handleErrorAndShowHelp(exception as Exception) } - 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 normalizeAndVerifySubCommand(cmd: String, ignoreCase: Boolean): String { + val normalized = cmd.handleCase(ignoreCase) + if (!matchSubCommand(normalized)) throw ArgumentParserException.invalidCommand(normalized) + return normalized } private fun parse(args: List): SubCommandData { - if (args.isEmpty()) { - throw ArgumentParserException.commandRequired() - } + if (hasRun) throw ArgumentParserException.noMoreParsing() + hasRun = true + if (args.isEmpty() && !emptyCommandLineAllowed) throw ArgumentParserException.commandRequired() checkExecutors() - val remaining = parseParameters(args, globalSubCommand.obj) + val remainingAfterGlobal = parseParameters(args, globalSubCommand.obj) validateMandatoryParameters(globalSubCommand.obj) - val resultSubCommand = parseSubCommand(remaining) + val resultSubCommand = parseSubCommand(remainingAfterGlobal) validateMandatoryParameters(resultSubCommand) if (remainingDescription != null) { - val additionalFromPipe = getRemainingParametersFromPipe() - if (additionalFromPipe.isNotEmpty()) { - resultSubCommand.additionalParameters += additionalFromPipe - } + resultSubCommand.additionalParameters += resolveRemainingFromPipe() } else if (resultSubCommand.additionalParameters.isNotEmpty()) { throw ArgumentParserException.noMoreOptions() } - return resultSubCommand } + private fun resolveRemainingFromPipe(): List { + return if (isStdInputReady()) getRemainingParametersFromPipe() else emptyList() + } + private fun parseSubCommand(args: List): SubCommandData { - val sub = args.firstOrNull()?.handleCase(ignoreCase)?.let { subCommands[it] } - ?: return globalSubCommand.obj.apply { additionalParameters = args } + val firstArg = args.firstOrNull()?.handleCase(ignoreCase) + val sub = subCommands[firstArg] ?: return globalSubCommand.obj.apply { + additionalParameters = args + } return sub.apply { additionalParameters = parseParameters(args.drop(1), this) } } private fun validateMandatoryParameters(subCommand: SubCommandData) { - subCommand.params.forEach { (_, parameter) -> + subCommand.params.filter { it.key == it.value.long }.forEach { (_, parameter) -> if (parameter.isMandatory && parameter.prop.value == null) { - throw ArgumentParserException.optionRequiresValue(parameter.long) + throw ArgumentParserException.optionRequired(parameter.long) } } } @@ -279,17 +334,23 @@ class CommandLine( private fun parseParameters(args: List, subCommand: SubCommandData): List { var index = 0 while (index < args.size) { - val parseResult = matchArgument(args[index]) ?: return args.drop(index) + val (key, inlineValue) = matchArgument(args[index]) ?: return args.drop(index) - var (key, inlineValue) = parseResult checkForHelpOrVersion(key) - key = key.handleCase(ignoreCase) + val normalizedKey = key.handleCase(ignoreCase) + val param = + subCommand.params[normalizedKey] ?: throw ArgumentParserException.unknownOption( + normalizedKey + ) - val param = subCommand.params[key] ?: throw ArgumentParserException.unknownOption(key) - - val (value, consumedExtra) = resolveParameterValue(args, index, key, inlineValue, param) + val (value, consumedExtra) = resolveParameterValue( + args, + index, + normalizedKey, + inlineValue, + param + ) param.type.convertAndSet(value, param.prop) - index += 1 + consumedExtra } return emptyList() @@ -312,7 +373,9 @@ class CommandLine( inlineValue.isNotBlank() -> inlineValue to 0 // --name --irgendwas ist nicht OK - currentIndex < args.size - 1 && !ARGUMENT_PATTERN.matches(args[currentIndex + 1]) -> { + currentIndex < args.size - 1 + && !ARGUMENT_PATTERN.matches(args[currentIndex + 1]) + && args[currentIndex + 1].isNotBlank() -> { args[currentIndex + 1] to 1 } @@ -322,12 +385,11 @@ class CommandLine( private fun getRemainingParametersFromPipe(): List { if (isStdInputReady()) { - var input: String? val pipe = mutableListOf() - do { - input = readlnOrNull() - input?.let { pipe.add(it) } - } while (!input.isNullOrEmpty()) + while (true) { + val input = readlnOrNull() ?: break + pipe.add(input) + } return pipe } return emptyList() @@ -347,37 +409,28 @@ class CommandLine( } } - private fun showHelp( + private fun handleErrorAndShowHelp( e: Exception?, ): Nothing { - val sub = !subCommands.isEmpty() - val globalParam = globalSubCommand.obj.params.isNotEmpty() - val remain = remainingDescription != null + val hasSubCommands = subCommands.isNotEmpty() + val hasGlobalParams = globalSubCommand.obj.params.isNotEmpty() + val allowsRemaining = remainingDescription != null println("\n${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:") - } + println("${programVersion.description}\n") + + val usagePrefix = "usage: ${programVersion.name}" + val globalPart = + if (hasGlobalParams) "[${if (hasSubCommands) "GLOBAL " else ""}PARAMETER]" else "" + val subPart = if (hasSubCommands) " subcommand [PARAMETER]" else "" + val remainPart = if (allowsRemaining) " <$remainingDescription>" else "" + + println("$usagePrefix $globalPart$subPart$remainPart\n") + if (hasGlobalParams) { + println("${if (hasSubCommands) "Global " else ""}Parameters:") } showParameters(globalSubCommand.obj.params) - if (sub) { + if (hasSubCommands) { println() println("Subcommands:") showSubcommands(subCommands) @@ -400,21 +453,20 @@ class CommandLine( 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 ""}") + val parmStr = INDENT + INDENT + + (param.short?.let { "-$it, " } ?: " ") + + "--${param.long}" + if (param.type != ParameterType.BOOLEAN) " <...>" else "" + len = maxOf(len, parmStr.length) + Pair(parmStr, "${param.description} ${if (param.isMandatory) "*" else ""}") }.forEach { println(it.first.padEnd(len + 3) + it.second) } - } private fun showVersion(): Nothing { diff --git a/src/commonMain/kotlin/net/keinekekse/args/internal.kt b/src/commonMain/kotlin/net/keinekekse/args/internal.kt index cc7a879..1c96776 100644 --- a/src/commonMain/kotlin/net/keinekekse/args/internal.kt +++ b/src/commonMain/kotlin/net/keinekekse/args/internal.kt @@ -157,42 +157,23 @@ internal class ArgumentParserException(message: String?) : RuntimeException(mess 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.") @@ -210,7 +191,7 @@ 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 ARGUMENT_PATTERN = """^(?:-(${SHORT_NAME})|--(${NAME}))(?:[=:](.*)|$)$""".toRegex() internal val NAME_PATTERN = """^${NAME}$""".toRegex() internal val SHORT_NAME_PATTERN = """^${SHORT_NAME}$""".toRegex() diff --git a/src/commonMain/kotlin/net/keinekekse/util/Version.kt b/src/commonMain/kotlin/net/keinekekse/util/Version.kt index c2465c1..7ed8f34 100644 --- a/src/commonMain/kotlin/net/keinekekse/util/Version.kt +++ b/src/commonMain/kotlin/net/keinekekse/util/Version.kt @@ -3,14 +3,14 @@ package net.keinekekse.util import net.keinekekse.version.Version object VERSION: Version { - override val version = "0.3.0-beta.8+build.117" - override val fullVersion = "0.3.0-beta.8+build.117" + override val version = "0.3.0-beta.19+build.129" + override val fullVersion = "0.3.0-beta.19+build.129" override val major = 0 override val minor = 3 override val patch = 0 - override val preRelease = "beta.8" - override val buildMetadata = "build.117" - override val buildDate = "2026-01-19T00:08:01.397532Z" + override val preRelease = "beta.19" + override val buildMetadata = "build.129" + override val buildDate = "2026-01-19T20:39:57.564140Z" override val displayName = "Utility Library" override val copyright = "Copyright (c) 2025-2026 Hartmut Bauer" override val name = "util" diff --git a/src/jvmMain/kotlin/net/keinekekse/args/annotation/impl.kt b/src/jvmMain/kotlin/net/keinekekse/args/annotation/impl.kt index 5a784ec..b90c00e 100644 --- a/src/jvmMain/kotlin/net/keinekekse/args/annotation/impl.kt +++ b/src/jvmMain/kotlin/net/keinekekse/args/annotation/impl.kt @@ -386,19 +386,16 @@ private fun resolveParameterValue( args: List, currentIndex: Int, key: String, - inlineValue: String, + parameterValue: String, param: CmdParameter, ): Pair { return when { param.type == ParameterType.BOOLEAN -> { - if (inlineValue.isNotBlank()) throw ArgumentParserException.optionNoValue(key) + if (parameterValue.isNotBlank()) throw ArgumentParserException.optionNoValue(key) TRUE to 0 } - // --name=--irgendwas ist OK - inlineValue.isNotBlank() -> inlineValue to 0 - - // --name --irgendwas ist nicht OK + parameterValue.isNotBlank() -> parameterValue to 0 currentIndex < args.size - 1 && !ARGUMENT_PATTERN.matches(args[currentIndex + 1]) -> { args[currentIndex + 1] to 1 }