• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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         // Scan for enum help text and style for the terminal.
124         val styledProlog = styleEnumHelpTextIfNeeded(prolog, mutableMapOf(), terminal)
125 
126         // Use the default help format.
127         val help =
128             super.formatHelp(
129                 styledProlog,
130                 epilog,
131                 transformedParameters,
132                 formattedProgramName,
133             )
134 
135         return removePadding(help)
136     }
137 
138     /**
139      * Removes additional padding added to help to force a fixed column width.
140      *
141      * This also removes trailing white space.
142      */
143     private fun removePadding(help: String): String = buildString {
144         val iterator = help.lines().iterator()
145         while (iterator.hasNext()) {
146             val line = iterator.next()
147 
148             // Try and remove any padding if any.
149             val withoutPadding = line.replace(namePadding, "")
150 
151             // Check if any padding was found and removed.
152             if (line != withoutPadding) {
153                 append(withoutPadding)
154 
155                 // Get the length of the line without padding as it will appear in the terminal,
156                 // i.e. excluding any terminal styling characters.
157                 val length = withoutPadding.graphemeLength
158 
159                 // If the name and first line of the description can fit on the same line then merge
160                 // them together.
161                 if (length < MAX_WIDTH_FOR_DESCRIPTION_ON_SAME_LINE) {
162                     // Make sure that there is a next line. This will happen if no help is provided
163                     // and this is the last option/argument.
164                     if (iterator.hasNext()) {
165                         val nextLine = iterator.next()
166                         // Make sure that it is indented as much as the current line.
167                         if (nextLine.length >= length) {
168                             val reducedIndent = nextLine.substring(length)
169                             append(reducedIndent)
170                         }
171                     }
172                 }
173             } else {
174                 append(line.trimEnd())
175             }
176             if (iterator.hasNext()) {
177                 append("\n")
178             }
179         }
180     }
181 
182     override fun renderArgumentName(name: String): String {
183         return terminal.bold(super.renderArgumentName(name)) + namePadding
184     }
185 
186     override fun renderHelpText(help: String, tags: Map<String, String>): String {
187         // Copy the tags as they may be modified.
188         val mutableTags = TreeMap(tags)
189 
190         // Scan the help text to see if it contains enum values and if it does then style them
191         // accordingly.
192         val styledHelp = styleEnumHelpTextIfNeeded(help, mutableTags, terminal)
193 
194         // Add any additional help text.
195         return super.renderHelpText(styledHelp, mutableTags)
196     }
197 
198     override fun renderOptionName(name: String): String {
199         return terminal.bold(super.renderOptionName(name)) + namePadding
200     }
201 
202     override fun renderSectionTitle(title: String): String {
203         return terminal.colorize(super.renderSectionTitle(title), TerminalColor.YELLOW)
204     }
205 
206     override fun renderSubcommandName(name: String): String {
207         return terminal.bold(super.renderSubcommandName(name)) + namePadding
208     }
209 }
210