All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.github.ajalt.clikt.output.CliktHelpFormatter.kt Maven / Gradle / Ivy

package com.github.ajalt.clikt.output

import com.github.ajalt.clikt.mpp.graphemeLengthMpp
import com.github.ajalt.clikt.mpp.readEnvvar

@Suppress("MemberVisibilityCanBePrivate")
open class CliktHelpFormatter(
        protected val indent: String = "  ",
        width: Int? = null,
        maxWidth: Int = 78,
        maxColWidth: Int? = null,
        protected val usageTitle: String = "Usage:",
        protected val optionsTitle: String = "Options:",
        protected val argumentsTitle: String = "Arguments:",
        protected val commandsTitle: String = "Commands:",
        protected val optionsMetavar: String = "[OPTIONS]",
        protected val commandMetavar: String = "COMMAND [ARGS]...",
        protected val colSpacing: Int = 2,
        protected val requiredOptionMarker: String? = null,
        protected val showDefaultValues: Boolean = false,
        protected val showRequiredTag: Boolean = false
) : HelpFormatter {
    protected val width: Int = when (width) {
        null -> minOf(maxWidth, readEnvvar("COLUMNS")?.toInt() ?: maxWidth)
        else -> width
    }

    protected val maxColWidth: Int = maxColWidth ?: (this.width / 2.5).toInt()

    override fun formatUsage(parameters: List, programName: String): String {
        return buildString { this.addUsage(parameters, programName) }
    }

    override fun formatHelp(
            prolog: String,
            epilog: String,
            parameters: List,
            programName: String
    ) = buildString {
        addUsage(parameters, programName)
        addProlog(prolog)
        addOptions(parameters)
        addArguments(parameters)
        addCommands(parameters)
        addEpilog(epilog)
    }

    protected open fun StringBuilder.addUsage(
            parameters: List,
            programName: String
    ) {
        val prog = "${renderSectionTitle(usageTitle)} $programName"
        val usage = buildString {
            if (parameters.any { it is HelpFormatter.ParameterHelp.Option }) {
                append(optionsMetavar)
            }

            parameters.filterIsInstance().forEach {
                append(" ")
                if (!it.required) append("[")
                append(it.name)
                if (!it.required) append("]")
                if (it.repeatable) append("...")
            }

            if (parameters.any { it is HelpFormatter.ParameterHelp.Subcommand }) {
                append(" ").append(commandMetavar)
            }
        }

        if (usage.isEmpty()) {
            append(prog)
        } else if (prog.graphemeLength >= width - 20) {
            append(prog).append("\n")
            val usageIndent = " ".repeat(minOf(width / 3, 11))
            usage.wrapText(this, width, usageIndent, usageIndent)
        } else {
            val usageIndent = " ".repeat(prog.length + 1)
            usage.wrapText(this, width, "$prog ", usageIndent)
        }
    }

    protected open fun StringBuilder.addProlog(prolog: String) {
        if (prolog.isNotEmpty()) {
            append("\n\n")
            prolog.wrapText(this, width, initialIndent = "  ", subsequentIndent = "  ")
        }
    }

    protected open fun StringBuilder.addOptions(parameters: List) {
        val groupsByName = parameters.filterIsInstance().associateBy { it.name }
        parameters.filterIsInstance()
                .groupBy { it.groupName }
                .toList()
                .sortedBy { it.first == null }
                .forEach { (title, params) ->
                    addOptionGroup(title?.let { "$it:" } ?: optionsTitle, groupsByName[title]?.help, params)
                }
    }

    protected open fun StringBuilder.addOptionGroup(title: String, help: String?, parameters: List) {
        val options = parameters.map {
            val names = mutableListOf(joinNamesForOption(it.names))
            if (it.secondaryNames.isNotEmpty()) names += joinNamesForOption(it.secondaryNames)
            DefinitionRow(
                    col1 = names.joinToString(" / ", postfix = optionMetavar(it)),
                    col2 = renderHelpText(it.help, it.tags),
                    marker = if (HelpFormatter.Tags.REQUIRED in it.tags) requiredOptionMarker else null
            )
        }
        if (options.isNotEmpty()) {
            append("\n")
            section(title)
            if (help != null) append("\n")
            help?.wrapText(this, width, initialIndent = "  ", subsequentIndent = "  ")
            if (help != null) append("\n\n")
            appendDefinitionList(options)
        }
    }

    protected open fun StringBuilder.addArguments(parameters: List) {
        val arguments = parameters.filterIsInstance().map {
            DefinitionRow(renderArgumentName(it.name), renderHelpText(it.help, it.tags))
        }
        if (arguments.isNotEmpty() && arguments.any { it.col2.isNotEmpty() }) {
            append("\n")
            section(argumentsTitle)
            appendDefinitionList(arguments)
        }
    }

    protected open fun StringBuilder.addCommands(parameters: List) {
        val commands = parameters.filterIsInstance().map {
            DefinitionRow(renderSubcommandName(it.name), renderHelpText(it.help, it.tags))
        }
        if (commands.isNotEmpty()) {
            append("\n")
            section(commandsTitle)
            appendDefinitionList(commands)
        }
    }

    protected open fun StringBuilder.addEpilog(epilog: String) {
        if (epilog.isNotEmpty()) {
            append("\n\n")
            epilog.wrapText(this, width)
        }
    }

    protected open fun renderHelpText(help: String, tags: Map): String {
        val renderedTags = tags.asSequence()
                .filter { (k, v) -> shouldShowTag(k, v) }
                .joinToString(" ") { (k, v) -> renderTag(k, v) }
        return if (renderedTags.isEmpty()) help else "$help $renderedTags"

    }

    protected open fun shouldShowTag(tag: String, value: String): Boolean {
        return when (tag) {
            HelpFormatter.Tags.DEFAULT -> showDefaultValues && value.isNotBlank()
            HelpFormatter.Tags.REQUIRED -> showRequiredTag
            else -> true
        }
    }

    protected open fun joinNamesForOption(names: Set): String {
        return names.sortedBy { it.startsWith("--") }.joinToString(", ") { renderOptionName(it) }
    }

    protected open fun renderTag(tag: String, value: String): String {
        return if (value.isBlank()) "($tag)" else "($tag: $value)"
    }

    protected open fun renderOptionName(name: String): String = name
    protected open fun renderArgumentName(name: String): String = name
    protected open fun renderSubcommandName(name: String): String = name
    protected open fun renderSectionTitle(title: String) = title

    protected open fun optionMetavar(option: HelpFormatter.ParameterHelp.Option): String {
        if (option.metavar == null) return ""
        val metavar = " " + option.metavar
        if (option.nvalues > 1) return "$metavar..."
        return metavar
    }

    protected fun StringBuilder.appendDefinitionList(rows: List) {
        if (rows.isEmpty()) return
        val firstWidth = measureFirstColumn(rows)
        for ((i, row) in rows.withIndex()) {
            val (col1, col2, marker) = row
            if (i > 0) append("\n")

            val firstIndent = when {
                marker.isNullOrEmpty() -> indent
                else -> marker + indent.drop(marker.graphemeLength).ifEmpty { " " }
            }
            val subsequentIndent = " ".repeat(firstIndent.graphemeLength + firstWidth + colSpacing)

            if (col2.isBlank()) {
                append(firstIndent).append(col1)
            } else {
                val initialIndent = if (col1.graphemeLength > maxColWidth) {
                    // If the first column is too wide, append it and start the second column on a new line
                    append(firstIndent).append(col1).append("\n")
                    subsequentIndent
                } else {
                    // If the first column fits, use it as the initial indent for wrapping
                    buildString {
                        append(firstIndent).append(col1)
                        // Pad the difference between this column's width and the table's first column width
                        repeat(firstWidth - col1.graphemeLength + colSpacing) { append(" ") }
                    }
                }

                col2.wrapText(this, width, initialIndent, subsequentIndent)
            }
        }
    }

    private fun measureFirstColumn(rows: List): Int =
            rows.maxBy { it.col1.graphemeLength }?.col1?.graphemeLength?.coerceAtMost(maxColWidth) ?: maxColWidth

    private fun StringBuilder.section(title: String) {
        append("\n").append(renderSectionTitle(title)).append("\n")
    }

    /** The number of visible characters in a string */
    protected val String.graphemeLength: Int get() = graphemeLengthMpp

    protected data class DefinitionRow(val col1: String, val col2: String, val marker: String? = null)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy