Command.kt

  1. package com.hexagontk.shell

  2. import com.hexagontk.helpers.requireNotBlank

  3. /**
  4.  * A program can have multiple commands with their own set of options and positional parameters.
  5.  *
  6.  * TODO Support aliases
  7.  */
  8. class Command(
  9.     val name: String,
  10.     val title: String? = null,
  11.     val description: String? = null,
  12.     val properties: Set<Property<*>> = emptySet(),
  13.     val subcommands: Set<Command> = emptySet(),
  14. ) {
  15.     val flags: Set<Flag> =
  16.         properties.filterIsInstance<Flag>().toSet()

  17.     val options: Set<Option<*>> =
  18.         properties.filterIsInstance<Option<*>>().toSet()

  19.     val parameters: Set<Parameter<*>> =
  20.         properties.filterIsInstance<Parameter<*>>().toSet()

  21.     val propertiesMap: Map<String, Property<*>> =
  22.         properties
  23.             .flatMap { p -> p.names.map { it to p } }
  24.             .toMap()

  25.     val optionsMap: Map<String, Option<*>> =
  26.         propertiesMap
  27.             .filterValues { it is Option<*> }
  28.             .mapValues { it.value as Option<*> }

  29.     val parametersMap: Map<String, Parameter<*>> =
  30.         propertiesMap
  31.             .filterValues { it is Parameter<*> }
  32.             .mapValues { it.value as Parameter<*> }

  33.     val subcommandsMap: Map<String, Command> =
  34.         nestedSubcommands().associateBy { it.name }

  35.     private val emptyPropertiesMap: Map<String, Property<*>> =
  36.         propertiesMap.mapValues { (_, v) -> v.clearValues() }

  37.     private val emptyParametersList: List<Parameter<*>> by lazy {
  38.         parameters.map { it.clearValues() }
  39.     }

  40.     init {
  41.         requireNotBlank(Command::name)
  42.         requireNotBlank(Command::title)
  43.         requireNotBlank(Command::description)

  44.         if (parametersMap.isNotEmpty()) {
  45.             val parameters = parametersMap.values.reversed().drop(1)
  46.             require(parameters.all { !it.multiple }) {
  47.                 "Only the last positional parameter can be multiple"
  48.             }
  49.         }
  50.     }

  51.     fun findCommand(args: Iterable<String>): Command {
  52.         val line = args.joinToString(" ")
  53.         return subcommandsMap
  54.             .mapKeys { it.key.removePrefix("$name ") }
  55.             .entries
  56.             .sortedByDescending { it.key.count { c -> c == ' ' } }
  57.             .find { line.contains(it.key) }
  58.             ?.let { (k, v) -> Command(k, v.title, v.description, v.properties, v.subcommands) }
  59.             ?: this
  60.     }

  61.     fun parse(args: List<String>): Command {
  62.         val argsIterator = args.iterator()
  63.         var parsedProperties = emptyList<Property<*>>()
  64.         var parsedParameter = 0

  65.         argsIterator.forEach { value ->
  66.             parsedProperties = when {
  67.                 value.startsWith("--") ->
  68.                     parsedProperties + parseOption(value.removePrefix("--"), argsIterator)

  69.                 value.startsWith('-') ->
  70.                     parsedProperties + parseOptions(value.removePrefix("-"), argsIterator)

  71.                 else ->
  72.                     parsedProperties + parseParameter(value, ++parsedParameter)
  73.             }
  74.         }

  75.         val groupedProperties = addDefaultProperties(parsedProperties.groupValues())
  76.         checkMandatoryProperties(groupedProperties)
  77.         return Command(name, title, description, groupedProperties.toSet(), subcommands)
  78.     }

  79.     private fun addDefaultProperties(groupedProperties: List<Property<*>>): List<Property<*>> =
  80.         groupedProperties + properties
  81.             .filter { it.optional && it.values.isNotEmpty() }
  82.             .filterNot { it.names.any { n -> n in groupedProperties.flatMap { gp -> gp.names } } }

  83.     @Suppress("UNCHECKED_CAST") // Types checked at runtime
  84.     fun <T : Any> propertyValues(name: String): List<T> =
  85.         propertiesMap[name]?.values?.mapNotNull { it as? T } ?: emptyList()

  86.     fun <T : Any> propertyValueOrNull(name: String): T? =
  87.         propertyValues<T>(name).firstOrNull()

  88.     fun <T : Any> propertyValue(name: String): T {
  89.         return propertyValueOrNull(name) ?: error("Property '$name' does not have a value")
  90.     }

  91.     private fun checkMandatoryProperties(parsedProperties: List<Property<*>>) {
  92.         val mandatoryProperties = properties.filterNot { it.optional }
  93.         val names = parsedProperties.flatMap { it.names }
  94.         val missingProperties = mandatoryProperties.filterNot { it.names.any { n -> n in names } }
  95.         check(missingProperties.isEmpty()) {
  96.             val missingNames = missingProperties.joinToString(", ") { "'${it.names.first()}'" }
  97.             "Missing properties: $missingNames"
  98.         }
  99.     }

  100.     private fun List<Property<*>>.groupValues(): List<Property<*>> =
  101.         groupBy { it.names }
  102.             .map { (_, v) ->
  103.                 v.reduceIndexed { i, a, b ->
  104.                     if (a.multiple) a.addValues(b)
  105.                     else error("Unknown argument at position ${i + 1}: ${b.values.first()}")
  106.                 }
  107.             }

  108.     private fun parseParameter(value: String, parsedParameter: Int): Property<*> =
  109.         (emptyParametersList.getOrNull(parsedParameter) ?: emptyParametersList.lastOrNull())
  110.             ?.addValue(value)
  111.             ?: error("No parameters")

  112.     private fun parseOptions(
  113.         names: String, argsIterator: Iterator<String>
  114.     ): Collection<Property<*>> {
  115.         val namesIterator = names.iterator()
  116.         var result = emptyList<Property<*>>()

  117.         namesIterator.forEach {
  118.             val name = it.toString()
  119.             val isOption = optionsMap.contains(name)
  120.             val option = if (isOption && namesIterator.hasNext()) {
  121.                 val firstValueChar = namesIterator.next()
  122.                 val valueStart = if (firstValueChar != '=') "=$firstValueChar" else firstValueChar
  123.                 val buffer = StringBuffer(name + valueStart)

  124.                 namesIterator.forEachRemaining(buffer::append)
  125.                 buffer.toString()
  126.             }
  127.             else name

  128.             result = result + parseOption(option, argsIterator)
  129.         }

  130.         return result
  131.     }

  132.     private fun parseOption(option: String, propertiesIterator: Iterator<String>): Property<*> {
  133.         val nameValue = option.split('=', limit = 2)
  134.         val name = nameValue.first()
  135.         val property = emptyPropertiesMap[name] ?: error("Option '$name' not found")
  136.         val value =
  137.             if (property is Option<*>) nameValue.getOrNull(1) ?: propertiesIterator.next()
  138.             else "true"

  139.         return property.addValue(value)
  140.     }

  141.     private fun nestedSubcommands(): Set<Command> =
  142.         subcommands
  143.             .map {
  144.                 Command("$name ${it.name}", it.title, it.description, it.properties, it.subcommands)
  145.             }
  146.             .let { c -> c + c.flatMap { it.nestedSubcommands() } }
  147.             .toSet()

  148.     fun contains(flag: Flag, args: Iterable<String>): Boolean =
  149.         flags
  150.             .flatMap { it.names }
  151.             .any { it in flag.names }
  152.             && args
  153.                 .map { it.dropWhile { c -> c == '-' } }
  154.                 .any { it in flag.names }

  155.     // TODO Only used in tests
  156.     fun copy(
  157.         name: String = this.name,
  158.         title: String? = this.title,
  159.         description: String? = this.description,
  160.         properties: Set<Property<*>> = this.properties,
  161.         subcommands: Set<Command> = this.subcommands,
  162.     ): Command =
  163.         Command(name, title, description, properties, subcommands)

  164.     // TODO Only used in tests
  165.     override fun equals(other: Any?): Boolean {
  166.         if (this === other) return true
  167.         if (javaClass != other?.javaClass) return false

  168.         other as Command

  169.         if (name != other.name) return false
  170.         if (title != other.title) return false
  171.         if (description != other.description) return false
  172.         if (properties != other.properties) return false
  173.         if (subcommands != other.subcommands) return false

  174.         return true
  175.     }

  176.     // TODO Only used in tests
  177.     override fun hashCode(): Int {
  178.         var result = name.hashCode()
  179.         result = 31 * result + (title?.hashCode() ?: 0)
  180.         result = 31 * result + (description?.hashCode() ?: 0)
  181.         result = 31 * result + properties.hashCode()
  182.         result = 31 * result + subcommands.hashCode()
  183.         return result
  184.     }
  185. }