Command.kt
- package com.hexagontk.shell
- import com.hexagontk.helpers.requireNotBlank
- /**
- * A program can have multiple commands with their own set of options and positional parameters.
- *
- * TODO Support aliases
- */
- class Command(
- val name: String,
- val title: String? = null,
- val description: String? = null,
- val properties: Set<Property<*>> = emptySet(),
- val subcommands: Set<Command> = emptySet(),
- ) {
- val flags: Set<Flag> =
- properties.filterIsInstance<Flag>().toSet()
- val options: Set<Option<*>> =
- properties.filterIsInstance<Option<*>>().toSet()
- val parameters: Set<Parameter<*>> =
- properties.filterIsInstance<Parameter<*>>().toSet()
- val propertiesMap: Map<String, Property<*>> =
- properties
- .flatMap { p -> p.names.map { it to p } }
- .toMap()
- val optionsMap: Map<String, Option<*>> =
- propertiesMap
- .filterValues { it is Option<*> }
- .mapValues { it.value as Option<*> }
- val parametersMap: Map<String, Parameter<*>> =
- propertiesMap
- .filterValues { it is Parameter<*> }
- .mapValues { it.value as Parameter<*> }
- val subcommandsMap: Map<String, Command> =
- nestedSubcommands().associateBy { it.name }
- private val emptyPropertiesMap: Map<String, Property<*>> =
- propertiesMap.mapValues { (_, v) -> v.clearValues() }
- private val emptyParametersList: List<Parameter<*>> by lazy {
- parameters.map { it.clearValues() }
- }
- init {
- requireNotBlank(Command::name)
- requireNotBlank(Command::title)
- requireNotBlank(Command::description)
- if (parametersMap.isNotEmpty()) {
- val parameters = parametersMap.values.reversed().drop(1)
- require(parameters.all { !it.multiple }) {
- "Only the last positional parameter can be multiple"
- }
- }
- }
- fun findCommand(args: Iterable<String>): Command {
- val line = args.joinToString(" ")
- return subcommandsMap
- .mapKeys { it.key.removePrefix("$name ") }
- .entries
- .sortedByDescending { it.key.count { c -> c == ' ' } }
- .find { line.contains(it.key) }
- ?.let { (k, v) -> Command(k, v.title, v.description, v.properties, v.subcommands) }
- ?: this
- }
- fun parse(args: List<String>): Command {
- val argsIterator = args.iterator()
- var parsedProperties = emptyList<Property<*>>()
- var parsedParameter = 0
- argsIterator.forEach { value ->
- parsedProperties = when {
- value.startsWith("--") ->
- parsedProperties + parseOption(value.removePrefix("--"), argsIterator)
- value.startsWith('-') ->
- parsedProperties + parseOptions(value.removePrefix("-"), argsIterator)
- else ->
- parsedProperties + parseParameter(value, ++parsedParameter)
- }
- }
- val groupedProperties = addDefaultProperties(parsedProperties.groupValues())
- checkMandatoryProperties(groupedProperties)
- return Command(name, title, description, groupedProperties.toSet(), subcommands)
- }
- private fun addDefaultProperties(groupedProperties: List<Property<*>>): List<Property<*>> =
- groupedProperties + properties
- .filter { it.optional && it.values.isNotEmpty() }
- .filterNot { it.names.any { n -> n in groupedProperties.flatMap { gp -> gp.names } } }
- @Suppress("UNCHECKED_CAST") // Types checked at runtime
- fun <T : Any> propertyValues(name: String): List<T> =
- propertiesMap[name]?.values?.mapNotNull { it as? T } ?: emptyList()
- fun <T : Any> propertyValueOrNull(name: String): T? =
- propertyValues<T>(name).firstOrNull()
- fun <T : Any> propertyValue(name: String): T {
- return propertyValueOrNull(name) ?: error("Property '$name' does not have a value")
- }
- private fun checkMandatoryProperties(parsedProperties: List<Property<*>>) {
- val mandatoryProperties = properties.filterNot { it.optional }
- val names = parsedProperties.flatMap { it.names }
- val missingProperties = mandatoryProperties.filterNot { it.names.any { n -> n in names } }
- check(missingProperties.isEmpty()) {
- val missingNames = missingProperties.joinToString(", ") { "'${it.names.first()}'" }
- "Missing properties: $missingNames"
- }
- }
- private fun List<Property<*>>.groupValues(): List<Property<*>> =
- groupBy { it.names }
- .map { (_, v) ->
- v.reduceIndexed { i, a, b ->
- if (a.multiple) a.addValues(b)
- else error("Unknown argument at position ${i + 1}: ${b.values.first()}")
- }
- }
- private fun parseParameter(value: String, parsedParameter: Int): Property<*> =
- (emptyParametersList.getOrNull(parsedParameter) ?: emptyParametersList.lastOrNull())
- ?.addValue(value)
- ?: error("No parameters")
- private fun parseOptions(
- names: String, argsIterator: Iterator<String>
- ): Collection<Property<*>> {
- val namesIterator = names.iterator()
- var result = emptyList<Property<*>>()
- namesIterator.forEach {
- val name = it.toString()
- val isOption = optionsMap.contains(name)
- val option = if (isOption && namesIterator.hasNext()) {
- val firstValueChar = namesIterator.next()
- val valueStart = if (firstValueChar != '=') "=$firstValueChar" else firstValueChar
- val buffer = StringBuffer(name + valueStart)
- namesIterator.forEachRemaining(buffer::append)
- buffer.toString()
- }
- else name
- result = result + parseOption(option, argsIterator)
- }
- return result
- }
- private fun parseOption(option: String, propertiesIterator: Iterator<String>): Property<*> {
- val nameValue = option.split('=', limit = 2)
- val name = nameValue.first()
- val property = emptyPropertiesMap[name] ?: error("Option '$name' not found")
- val value =
- if (property is Option<*>) nameValue.getOrNull(1) ?: propertiesIterator.next()
- else "true"
- return property.addValue(value)
- }
- private fun nestedSubcommands(): Set<Command> =
- subcommands
- .map {
- Command("$name ${it.name}", it.title, it.description, it.properties, it.subcommands)
- }
- .let { c -> c + c.flatMap { it.nestedSubcommands() } }
- .toSet()
- fun contains(flag: Flag, args: Iterable<String>): Boolean =
- flags
- .flatMap { it.names }
- .any { it in flag.names }
- && args
- .map { it.dropWhile { c -> c == '-' } }
- .any { it in flag.names }
- // TODO Only used in tests
- fun copy(
- name: String = this.name,
- title: String? = this.title,
- description: String? = this.description,
- properties: Set<Property<*>> = this.properties,
- subcommands: Set<Command> = this.subcommands,
- ): Command =
- Command(name, title, description, properties, subcommands)
- // TODO Only used in tests
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as Command
- if (name != other.name) return false
- if (title != other.title) return false
- if (description != other.description) return false
- if (properties != other.properties) return false
- if (subcommands != other.subcommands) return false
- return true
- }
- // TODO Only used in tests
- override fun hashCode(): Int {
- var result = name.hashCode()
- result = 31 * result + (title?.hashCode() ?: 0)
- result = 31 * result + (description?.hashCode() ?: 0)
- result = 31 * result + properties.hashCode()
- result = 31 * result + subcommands.hashCode()
- return result
- }
- }