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