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> { override var value: List? = null } class OptionValue : ValueDelegate { 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 { fun convert(value: String): T } sealed class OptionType( val converter: OptionConverter, val hasParameter: Boolean = true, ) { companion object { private class StringConverter : OptionConverter { override fun convert(value: String) = value } private class LongConverter : OptionConverter { override fun convert(value: String) = value.toLongOrNull() ?: throw ArgumentParserException.invalidLongValue(value) } private class BooleanConverter : OptionConverter { override fun convert(value: String) = value == "true" } } open fun createOption(config: OptionConfig): Option = Option(config, this) fun convertAndSet(value: String, opt: Option) { opt.setValue(converter.convert(value)) } object STRING : OptionType(StringConverter()) object LONG : OptionType(LongConverter()) object BOOLEAN : OptionType(BooleanConverter(), hasParameter = false) { override fun createOption(config: OptionConfig): Option { return super.createOption(config).apply { setValue(false) } } } } data class Option( val config: OptionConfig, val type: OptionType, ) { val optionValue = OptionValue() fun setValue(value: T) { optionValue.value = value } fun isEmpty() = optionValue.isEmpty() } interface AddOptions { val options: List> fun stringOption(config: OptionConfig): OptionValue fun longOption(config: OptionConfig): OptionValue fun boolOption(config: OptionConfig): OptionValue fun createOption(config: OptionConfig, type: OptionType): OptionValue } /** * 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 = emptyList(), ) : AddOptions { override val options = mutableListOf>() 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 = 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` instance representing the value of the long option */ override fun longOption(config: OptionConfig): OptionValue = 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` instance representing the value of the Boolean option */ override fun boolOption(config: OptionConfig): OptionValue = createOption( config.copy(required = false), OptionType.BOOLEAN ) override fun createOption( config: OptionConfig, type: OptionType, ): OptionValue { DefaultOptionValidator.validateOption(config, options) @Suppress("UNCHECKED_CAST") return type.createOption(config).also { options.add(it as Option) }.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 = mutableListOf()) : MutableList 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 = 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) { 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>() 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 = 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` that provides access to the parsed value * of the long option. */ override fun longOption(config: OptionConfig): OptionValue = 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` that provides access to the parsed value * of the boolean option. */ override fun boolOption(config: OptionConfig): OptionValue = createOption( config.copy(required = false), OptionType.BOOLEAN ) override fun createOption( config: OptionConfig, type: OptionType, ): OptionValue { try { if (isParsed) throw ArgumentParserException.noMoreOptions() DefaultOptionValidator.validateOption(config, options) @Suppress("UNCHECKED_CAST") return type.createOption(config).also { options.add(it as Option) }.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) { 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) protected fun parseArguments(args: Array, options: MutableList>) { 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>) { if (findOption("help", options)?.optionValue?.value == true) { showHelp() } } private fun findOption(name: String, options: MutableList>): Option? = 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, options: List>, parameters: ParameterValue, ) fun parseCommand(cmd: String): Boolean } private class DefaultArgumentParser : ArgumentParser { override fun parseArguments( args: Array, options: List>, 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? = ARGUMENT_PATTERN.matchEntire(arg)?.groupValues?.let { Pair(it[1] + it[2], it[3]) } private fun handleOption( key: String, value: String, args: Array, index: Int, options: List>, ): 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, value: String, args: Array, 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, 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>, 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, 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>) fun validateRequiredOptions(options: List>) } private object DefaultOptionValidator : OptionValidator { override fun validateOption(config: OptionConfig, existingOptions: List>) { validateOptionName(config.name) validateShortName(config.shortName) validateOptionUniqueness(config, existingOptions) } override fun validateRequiredOptions(options: List>) { 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>, ) { 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) } } }