1 /*
2 * 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.android.tools.metalava.ProgressTracker
20 import com.github.ajalt.clikt.core.CliktCommand
21 import com.github.ajalt.clikt.core.NoSuchOption
22 import com.github.ajalt.clikt.core.PrintHelpMessage
23 import com.github.ajalt.clikt.core.PrintMessage
24 import com.github.ajalt.clikt.core.UsageError
25 import com.github.ajalt.clikt.core.context
26 import com.github.ajalt.clikt.parameters.arguments.argument
27 import com.github.ajalt.clikt.parameters.arguments.multiple
28 import com.github.ajalt.clikt.parameters.groups.provideDelegate
29 import com.github.ajalt.clikt.parameters.options.eagerOption
30 import com.github.ajalt.clikt.parameters.options.flag
31 import com.github.ajalt.clikt.parameters.options.option
32 import java.io.InputStream
33 import java.io.PrintWriter
34
35 const val ARG_VERSION = "--version"
36
37 /**
38 * Main metalava command.
39 *
40 * If no subcommand is specified in the arguments that are passed to [parse] then this will invoke
41 * the subcommand called [defaultCommandName] passing in all the arguments not already consumed by
42 * Clikt options.
43 */
44 internal open class MetalavaCommand(
45 internal val executionEnvironment: ExecutionEnvironment,
46
47 /**
48 * The optional name of the default subcommand to run if no subcommand is provided in the
49 * command line arguments.
50 */
51 private val defaultCommandName: String? = null,
52 internal val progressTracker: ProgressTracker,
53 ) :
54 CliktCommand(
55 // Gather all the options and arguments into a list so that they can be handled by some
56 // non-Clikt option processor which it is assumed that the default command, if specified,
57 // has.
58 treatUnknownOptionsAsArgs = defaultCommandName != null,
59 // Call run on this command even if no sub-command is provided.
60 invokeWithoutSubcommand = true,
61 help =
62 """
63 Extracts metadata from source code to generate artifacts such as the signature files,
64 the SDK stub files, external annotations etc.
65 """
66 .trimIndent()
67 ) {
68
69 private val stdout = executionEnvironment.stdout
70 private val stderr = executionEnvironment.stderr
71
72 init {
<lambda>null73 context {
74 console = MetalavaConsole(executionEnvironment)
75
76 localization = MetalavaLocalization()
77
78 /**
79 * Disable built in help.
80 *
81 * See [showHelp] for an explanation.
82 */
83 helpOptionNames = emptySet()
84
85 // Override the help formatter to use Metalava's special formatter.
86 helpFormatter =
87 MetalavaHelpFormatter(
88 { common.terminal },
89 localization,
90 )
91 }
92
93 // Print the version number if requested.
94 eagerOption(
95 help = "Show the version and exit",
96 names = setOf(ARG_VERSION),
97 // Abort the processing of options immediately to display the version and exit.
<lambda>null98 action = { throw PrintVersionException() }
99 )
100
101 // Add the --print-stack-trace option.
102 eagerOption(
103 "--print-stack-trace",
104 help =
105 """
106 Print the stack trace of any exceptions that will cause metalava to exit.
107 (default: no stack trace)
108 """
109 .trimIndent(),
<lambda>null110 action = { printStackTrace = true }
111 )
112 }
113
114 /** Group of common options. */
115 val common by CommonOptions()
116
117 /**
118 * True if a stack trace should be output for any exception that is thrown and causes metalava
119 * to exit.
120 *
121 * Uses a real property that is set by an eager option action rather than a normal Clikt option
122 * so that it will be readable even if metalava aborts before it has been processed. Otherwise,
123 * exceptions that were thrown before the option was processed would cause this field to be
124 * accessed to see whether their stack trace should be printed. That access would fail and
125 * obscure the original error.
126 */
127 private var printStackTrace: Boolean = false
128
129 /**
130 * A custom, non-eager help option that allows [CommonOptions] like [CommonOptions.terminal] to
131 * be used when generating the help output.
132 *
133 * The built-in help option is eager and throws a [PrintHelpMessage] exception which aborts the
134 * processing of other options preventing their use when generating the help output.
135 *
136 * Currently, this does not support `-?` for help as Clikt considers that to be an invalid flag.
137 * However, `-?` is still supported for backwards compatibility using a workaround in
138 * [showHelpAndExitIfRequested].
139 */
140 private val showHelp by option("-h", "--help", help = "Show this message and exit").flag()
141
142 /** Property into which all the arguments (and unknown options) are gathered. */
143 private val flags by
144 argument(
145 name = "flags",
146 help = "See below.",
147 )
148 .multiple()
149
150 /** A list of actions to perform after the command has been executed. */
151 private val postCommandActions = mutableListOf<() -> Unit>()
152
153 /** Process the command, handling [MetalavaCliException]s. */
processnull154 fun process(args: Array<String>): Int {
155 var exitCode = 0
156 try {
157 processThrowCliException(args)
158 } catch (e: PrintVersionException) {
159 // Print the version and exit.
160 stdout.println("\n$commandName version: ${Version.VERSION}")
161 } catch (e: MetalavaCliException) {
162 stdout.flush()
163 stderr.flush()
164
165 if (printStackTrace) {
166 e.printStackTrace(stderr)
167 } else {
168 val prefix =
169 if (e.exitCode != 0) {
170 "Aborting: "
171 } else {
172 ""
173 }
174
175 if (e.stderr.isNotBlank()) {
176 stderr.println("\n${prefix}${e.stderr}")
177 }
178 if (e.stdout.isNotBlank()) {
179 stdout.println("\n${prefix}${e.stdout}")
180 }
181 }
182
183 exitCode = e.exitCode
184 }
185
186 // Perform any subcommand specific actions, e.g. flushing files they have opened, etc.
187 performPostCommandActions()
188
189 return exitCode
190 }
191
192 /**
193 * Register a command to run after the command has been executed and after any thrown
194 * [MetalavaCliException]s have been caught.
195 */
registerPostCommandActionnull196 fun registerPostCommandAction(action: () -> Unit) {
197 postCommandActions.add(action)
198 }
199
200 /** Perform actions registered by [registerPostCommandAction]. */
performPostCommandActionsnull201 fun performPostCommandActions() {
202 postCommandActions.forEach { it() }
203 }
204
205 /** Process the command, throwing [MetalavaCliException]s. */
processThrowCliExceptionnull206 fun processThrowCliException(args: Array<String>) {
207 try {
208 parse(args)
209 } catch (e: PrintHelpMessage) {
210 throw MetalavaCliException(
211 stdout = e.command.getFormattedHelp(),
212 exitCode = if (e.error) 1 else 0
213 )
214 } catch (e: PrintMessage) {
215 throw MetalavaCliException(
216 stdout = e.message ?: "",
217 exitCode = if (e.error) 1 else 0,
218 cause = e,
219 )
220 } catch (e: NoSuchOption) {
221 val message = createNoSuchOptionErrorMessage(e)
222 throw MetalavaCliException(
223 stderr = message,
224 exitCode = e.statusCode,
225 cause = e,
226 )
227 } catch (e: UsageError) {
228 val message = e.helpMessage()
229 throw MetalavaCliException(
230 stderr = message,
231 exitCode = e.statusCode,
232 cause = e,
233 )
234 }
235 }
236
237 /**
238 * Create an error message that incorporates the specific usage error as well as providing
239 * documentation for all the available options.
240 */
createNoSuchOptionErrorMessagenull241 private fun createNoSuchOptionErrorMessage(e: UsageError): String {
242 return buildString {
243 val errorContext = e.context ?: currentContext
244 e.message?.let { append(errorContext.localization.usageError(it)).append("\n\n") }
245 append(errorContext.command.getFormattedHelp())
246 }
247 }
248
249 /**
250 * Perform this command's actions.
251 *
252 * This is called after the command line parameters are parsed. If one of the sub-commands is
253 * invoked then this is called before the sub-commands parameters are parsed.
254 */
runnull255 override fun run() {
256 // Make this available to all sub-commands.
257 currentContext.obj = this
258
259 val subcommand = currentContext.invokedSubcommand
260 if (subcommand == null) {
261 showHelpAndExitIfRequested()
262
263 if (defaultCommandName != null) {
264 // Get any remaining arguments/options that were not handled by Clikt.
265 val remainingArgs = flags.toTypedArray()
266
267 // Get the default command.
268 val defaultCommand =
269 registeredSubcommands().singleOrNull { it.commandName == defaultCommandName }
270 ?: cliError(
271 "Invalid default command name '$defaultCommandName', expected one of '${registeredSubcommandNames().joinToString("', '")}'"
272 )
273
274 // No sub-command was provided so use the default subcommand.
275 defaultCommand.parse(remainingArgs, currentContext)
276 }
277 }
278 }
279
280 /**
281 * Show help and exit if requested.
282 *
283 * Help is requested if [showHelp] is true or [flags] contains `-?` or `-?`.
284 */
showHelpAndExitIfRequestednull285 private fun showHelpAndExitIfRequested() {
286 val remainingArgs = flags.toTypedArray()
287 // Output help and exit if requested.
288 if (showHelp || remainingArgs.contains("-?")) {
289 throw PrintHelpMessage(this)
290 }
291 }
292
293 /**
294 * Exception to use for the --version option to use for aborting the processing of options
295 * immediately and allow the exception handling code to treat it specially.
296 */
297 private class PrintVersionException : RuntimeException()
298 }
299
300 /**
301 * Get the containing [MetalavaCommand].
302 *
303 * It will always be set.
304 */
305 private val CliktCommand.metalavaCommand
306 get() = if (this is MetalavaCommand) this else currentContext.findObject()!!
307
308 /** The [ExecutionEnvironment] within which the command is being run. */
309 val CliktCommand.executionEnvironment
310 get() = metalavaCommand.executionEnvironment
311
312 /** The [PrintWriter] to use for error output from the command. */
313 val CliktCommand.stderr
314 get() = executionEnvironment.stderr
315
316 /** The [PrintWriter] to use for non-error output from the command. */
317 val CliktCommand.stdout
318 get() = executionEnvironment.stdout
319
320 /** The [InputStream] to use for input to the command. */
321 val CliktCommand.stdin
322 get() = executionEnvironment.stdin
323
324 val CliktCommand.commonOptions
325 // Retrieve the CommonOptions that is made available by the containing MetalavaCommand.
326 get() = metalavaCommand.common
327
328 val CliktCommand.terminal
329 // Retrieve the terminal from the CommonOptions.
330 get() = commonOptions.terminal
331
332 val CliktCommand.progressTracker
333 // Retrieve the ProgressTracker that is made available by the containing MetalavaCommand.
334 get() = metalavaCommand.progressTracker
335
CliktCommandnull336 fun CliktCommand.registerPostCommandAction(action: () -> Unit) {
337 metalavaCommand.registerPostCommandAction(action)
338 }
339