1 /* <lambda>null2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tools.metalava.cli.common 18 19 import com.github.ajalt.clikt.output.CliktHelpFormatter 20 import com.github.ajalt.clikt.output.HelpFormatter 21 import com.github.ajalt.clikt.output.HelpFormatter.ParameterHelp.Option 22 import com.github.ajalt.clikt.output.Localization 23 import java.util.TreeMap 24 25 private const val MAX_LINE_WIDTH = 120 26 27 /** 28 * The following value was chosen to produce the same indentation for option descriptions as is 29 * produced by [Options.usage]. 30 */ 31 private const val MAX_COLUMN_WIDTH = 41 32 33 /** 34 * There is no way to set a fixed column width for the first column containing the names of options, 35 * arguments and sub-commands in [CliktHelpFormatter]. It only supports setting a maximum column 36 * width. This provides padding that is added after the names of options, arguments and sub-commands 37 * to make them exceed the maximum column width. That will cause [CliktHelpFormatter] to always set 38 * the width of the column to the maximum, effectively making the maximum a fixed width. 39 * 40 * This will cause every option, argument or sub-command to have its description start on the 41 * following line even if that is unnecessary. The [MetalavaHelpFormatter.removePadding] method will 42 * correct that. 43 */ 44 private val namePadding = "X".repeat(MAX_COLUMN_WIDTH) 45 46 /** 47 * The maximum width for a line containing the name that can have the first line of the description 48 * on the same line. 49 * 50 * This is used by [MetalavaHelpFormatter.removePadding] when removing the [namePadding] from the 51 * generated help to determine whether the name and the first line of the description can be on the 52 * same line. 53 * 54 * This value was chosen to ensure that if Clikt would place the description on a separate line to 55 * the name when the name is not padded then it will continue to do so after the padding has been 56 * applied and removed. 57 */ 58 private const val MAX_WIDTH_FOR_DESCRIPTION_ON_SAME_LINE = MAX_COLUMN_WIDTH + 3 59 60 /** Metalava specific implementation of [CliktHelpFormatter]. */ 61 internal open class MetalavaHelpFormatter( 62 terminalSupplier: () -> Terminal, 63 localization: Localization, 64 ) : 65 CliktHelpFormatter( 66 localization = localization, 67 showDefaultValues = true, 68 showRequiredTag = true, 69 maxWidth = MAX_LINE_WIDTH, 70 maxColWidth = MAX_COLUMN_WIDTH, 71 ) { 72 73 /** 74 * Property for accessing the [Terminal] instance that should be used to style (or not) help 75 * text. 76 */ 77 protected val terminal: Terminal by lazy { terminalSupplier() } 78 79 /** 80 * The name of the group to which options will be added if they do not belong to another group. 81 * This has to match the name of the default options title minus the trailing `:`. 82 */ 83 private val defaultOptionGroupName = localization.optionsTitle().removeSuffix(":") 84 85 override fun formatHelp( 86 prolog: String, 87 epilog: String, 88 parameters: List<HelpFormatter.ParameterHelp>, 89 programName: String 90 ): String { 91 // Color the program name, there is no override to do that. 92 val formattedProgramName = terminal.colorize(programName, TerminalColor.BLUE) 93 94 val transformedParameters = 95 parameters 96 .asSequence() 97 // Force all options to belong to a group. This is needed because Clikt will order 98 // options without any group name (options like help and version) after option 99 // groups but metalava help needs those to come first. It is not possible (or at 100 // least not easy) to add group names to some of those options at creation time, so 101 // it is done here. 102 .map { 103 when (it) { 104 is Option -> 105 if (it.groupName == null) it.copy(groupName = defaultOptionGroupName) 106 else it 107 else -> it 108 } 109 } 110 // Map a composite option to multiple separate options for the purposes of help 111 // formatting only. 112 .flatMap { v -> 113 if (v is Option && v.isCompositeOption()) { 114 v.decompose() 115 } else { 116 sequenceOf(v) 117 } 118 } 119 // Force the options in the default group (like help) to come first. 120 .sortedBy { (it as? Option)?.groupName != defaultOptionGroupName } 121 .toList() 122 123 // Use the default help format. 124 val help = super.formatHelp(prolog, epilog, transformedParameters, formattedProgramName) 125 126 return removePadding(help) 127 } 128 129 /** 130 * Removes additional padding added to help to force a fixed column width. 131 * 132 * This also removes trailing white space. 133 */ 134 private fun removePadding(help: String): String = buildString { 135 val iterator = help.lines().iterator() 136 while (iterator.hasNext()) { 137 val line = iterator.next() 138 139 // Try and remove any padding if any. 140 val withoutPadding = line.replace(namePadding, "") 141 142 // Check if any padding was found and removed. 143 if (line != withoutPadding) { 144 append(withoutPadding) 145 146 // Get the length of the line without padding as it will appear in the terminal, 147 // i.e. excluding any terminal styling characters. 148 val length = withoutPadding.graphemeLength 149 150 // If the name and first line of the description can fit on the same line then merge 151 // them together. 152 if (length < MAX_WIDTH_FOR_DESCRIPTION_ON_SAME_LINE) { 153 // Make sure that there is a next line. This will happen if no help is provided 154 // and this is the last option/argument. 155 if (iterator.hasNext()) { 156 val nextLine = iterator.next() 157 // Make sure that it is indented as much as the current line. 158 if (nextLine.length >= length) { 159 val reducedIndent = nextLine.substring(length) 160 append(reducedIndent) 161 } 162 } 163 } 164 } else { 165 append(line.trimEnd()) 166 } 167 if (iterator.hasNext()) { 168 append("\n") 169 } 170 } 171 } 172 173 override fun renderArgumentName(name: String): String { 174 return terminal.bold(super.renderArgumentName(name)) + namePadding 175 } 176 177 override fun renderHelpText(help: String, tags: Map<String, String>): String { 178 // Copy the tags as they may be modified. 179 val mutableTags = TreeMap(tags) 180 181 // Scan the help text to see if it contains enum values and if it does then style them 182 // accordingly. 183 val styledHelp = styleEnumHelpTextIfNeeded(help, mutableTags, terminal) 184 185 // Add any additional help text. 186 val helpText = super.renderHelpText(styledHelp, mutableTags) 187 188 // Remove any trailing NEL to prevent additional blank lines being added. This is done here 189 // rather than before as super.renderHelpText(...) may append content to the end which would 190 // need to be separated from the rest of the text by a blank line. 191 return helpText.removeSuffix(HARD_NEWLINE) 192 } 193 194 override fun renderOptionName(name: String): String { 195 return terminal.bold(super.renderOptionName(name)) + namePadding 196 } 197 198 override fun renderSectionTitle(title: String): String { 199 return terminal.colorize(super.renderSectionTitle(title), TerminalColor.YELLOW) 200 } 201 202 override fun renderSubcommandName(name: String): String { 203 return terminal.bold(super.renderSubcommandName(name)) + namePadding 204 } 205 } 206