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