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