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.completion.CompletionCandidates
20 import com.github.ajalt.clikt.core.GroupableOption
21 import com.github.ajalt.clikt.core.ParameterHolder
22 import com.github.ajalt.clikt.output.HelpFormatter
23 import com.github.ajalt.clikt.output.HelpFormatter.ParameterHelp.Option
24 import com.github.ajalt.clikt.parameters.arguments.ProcessedArgument
25 import com.github.ajalt.clikt.parameters.arguments.RawArgument
26 import com.github.ajalt.clikt.parameters.arguments.convert
27 import com.github.ajalt.clikt.parameters.options.NullableOption
28 import com.github.ajalt.clikt.parameters.options.OptionCallTransformContext
29 import com.github.ajalt.clikt.parameters.options.OptionDelegate
30 import com.github.ajalt.clikt.parameters.options.OptionWithValues
31 import com.github.ajalt.clikt.parameters.options.RawOption
32 import com.github.ajalt.clikt.parameters.options.convert
33 import com.github.ajalt.clikt.parameters.options.default
34 import com.github.ajalt.clikt.parameters.options.option
35 import com.github.ajalt.clikt.parameters.types.choice
36 import java.io.File
37 import kotlin.properties.ReadOnlyProperty
38 import kotlin.reflect.KProperty
39
40 // This contains extensions methods for creating custom Clikt options.
41
42 /** Convert the option to a [File] that represents an existing file. */
43 fun RawOption.existingFile(): NullableOption<File, File> {
44 return fileConversion(::stringToExistingFile)
45 }
46
47 /** Convert the argument to a [File] that represents an existing file. */
existingFilenull48 fun RawArgument.existingFile(): ProcessedArgument<File, File> {
49 return fileConversion(::stringToExistingFile)
50 }
51
52 /** Convert the argument to a [File] that represents an existing directory. */
RawArgumentnull53 fun RawArgument.existingDir(): ProcessedArgument<File, File> {
54 return fileConversion(::stringToExistingDir)
55 }
56
57 /** Convert the option to a [File] that represents a new file. */
newFilenull58 fun RawOption.newFile(): NullableOption<File, File> {
59 return fileConversion(::stringToNewFile)
60 }
61
62 /** Convert the option to a [File] that represents a new directory. */
newDirnull63 fun RawOption.newDir(): NullableOption<File, File> {
64 return fileConversion(::stringToNewDir)
65 }
66
67 /** Convert the argument to a [File] that represents a new file. */
newFilenull68 fun RawArgument.newFile(): ProcessedArgument<File, File> {
69 return fileConversion(::stringToNewFile)
70 }
71
72 /** Convert the argument to a [File] that represents a new directory. */
newDirnull73 fun RawArgument.newDir(): ProcessedArgument<File, File> {
74 return fileConversion(::stringToNewDir)
75 }
76
77 /** Convert the option to a [File] that represents a new or existing file. */
newOrExistingFilenull78 fun RawOption.newOrExistingFile(): NullableOption<File, File> {
79 return fileConversion(::stringToNewOrExistingFile)
80 }
81
82 /** Convert the option to a [File] using the supplied conversion function.. */
fileConversionnull83 private fun RawOption.fileConversion(conversion: (String) -> File): NullableOption<File, File> {
84 return convert({ localization.pathMetavar() }, CompletionCandidates.Path) { str ->
85 try {
86 conversion(str)
87 } catch (e: MetalavaCliException) {
88 e.message?.let { fail(it) } ?: throw e
89 }
90 }
91 }
92
93 /** Convert the argument to a [File] using the supplied conversion function. */
fileConversionnull94 fun RawArgument.fileConversion(conversion: (String) -> File): ProcessedArgument<File, File> {
95 return convert(CompletionCandidates.Path) { str ->
96 try {
97 conversion(str)
98 } catch (e: MetalavaCliException) {
99 e.message?.let { fail(it) } ?: throw e
100 }
101 }
102 }
103
104 /**
105 * Converts a path to a [File] that represents the absolute path, with the following special
106 * behavior:
107 * - "~" will be expanded into the home directory path.
108 * - If the given path starts with "@", it'll be converted into "@" + [file's absolute path]
109 */
fileForPathInnernull110 internal fun fileForPathInner(path: String): File {
111 // java.io.File doesn't automatically handle ~/ -> home directory expansion.
112 // This isn't necessary when metalava is run via the command line driver
113 // (since shells will perform this expansion) but when metalava is run
114 // directly, not from a shell.
115 if (path.startsWith("~/")) {
116 val home = System.getProperty("user.home") ?: return File(path)
117 return File(home + path.substring(1))
118 } else if (path.startsWith("@")) {
119 return File("@" + File(path.substring(1)).absolutePath)
120 }
121
122 return File(path).absoluteFile
123 }
124
125 /**
126 * Convert a string representing an existing directory to a [File].
127 *
128 * This will fail if:
129 * * The file is not a regular directory.
130 */
stringToExistingDirnull131 internal fun stringToExistingDir(value: String): File {
132 val file = fileForPathInner(value)
133 if (!file.isDirectory) {
134 throw MetalavaCliException("$file is not a directory")
135 }
136 return file
137 }
138
139 /**
140 * Convert a string representing a new directory to a [File].
141 *
142 * This will fail if:
143 * * the directory exists and cannot be deleted.
144 * * the directory cannot be created.
145 */
stringToNewDirnull146 internal fun stringToNewDir(value: String): File {
147 val output = fileForPathInner(value)
148 val ok =
149 if (output.exists()) {
150 if (output.isDirectory) {
151 output.deleteRecursively()
152 }
153 if (output.exists()) {
154 true
155 } else {
156 output.mkdir()
157 }
158 } else {
159 output.mkdirs()
160 }
161 if (!ok) {
162 throw MetalavaCliException("Could not create $output")
163 }
164
165 return output
166 }
167
168 /**
169 * Convert a string representing an existing file to a [File].
170 *
171 * This will fail if:
172 * * The file is not a regular file.
173 */
stringToExistingFilenull174 internal fun stringToExistingFile(value: String): File {
175 val file = fileForPathInner(value)
176 if (!file.isFile) {
177 throw MetalavaCliException("$file is not a file")
178 }
179 return file
180 }
181
182 /**
183 * Convert a string representing a new file to a [File].
184 *
185 * This will fail if:
186 * * the file is a directory.
187 * * the file exists and cannot be deleted.
188 * * the parent directory does not exist, and cannot be created.
189 */
stringToNewFilenull190 internal fun stringToNewFile(value: String): File {
191 val output = fileForPathInner(value)
192
193 if (output.exists()) {
194 if (output.isDirectory) {
195 throw MetalavaCliException("$output is a directory")
196 }
197 val deleted = output.delete()
198 if (!deleted) {
199 throw MetalavaCliException("Could not delete previous version of $output")
200 }
201 } else if (output.parentFile != null && !output.parentFile.exists()) {
202 val ok = output.parentFile.mkdirs()
203 if (!ok) {
204 throw MetalavaCliException("Could not create ${output.parentFile}")
205 }
206 }
207
208 return output
209 }
210
211 /**
212 * Convert a string representing a new or existing file to a [File].
213 *
214 * This will fail if:
215 * * the file is a directory.
216 * * the parent directory does not exist, and cannot be created.
217 */
stringToNewOrExistingFilenull218 internal fun stringToNewOrExistingFile(value: String): File {
219 val file = fileForPathInner(value)
220 if (!file.exists()) {
221 val parentFile = file.parentFile
222 if (parentFile != null && !parentFile.isDirectory) {
223 val ok = parentFile.mkdirs()
224 if (!ok) {
225 throw MetalavaCliException("Could not create $parentFile")
226 }
227 }
228 }
229 return file
230 }
231
232 // Unicode Next Line (NEL) character which forces Clikt to insert a new line instead of just
233 // collapsing the `\n` into adjacent spaces. Acts like an HTML <br/>.
234 const val HARD_NEWLINE = "\u0085"
235
236 /**
237 * Create a property delegate for an enum.
238 *
239 * This will generate help text that:
240 * * uses lower case version of the enum value name (with `_` replaced with `-`) as the value to
241 * supply on the command line.
242 * * formats the help for each enum value in its own block separated from surrounding blocks by
243 * blank lines.
244 * * will tag the default enum value in the help.
245 *
246 * @param names the possible names for the option that can be used on the command line.
247 * @param help the help for the option, does not need to include information about the default or
248 * the individual options as they will be added automatically.
249 * @param enumValueHelpGetter given an enum value return the help for it.
250 * @param key given an enum value return the value that must be specified on the command line. This
251 * is used to create a bidirectional mapping so that command line option can be mapped to the enum
252 * value and the default enum value mapped back to the default command line option. Defaults to
253 * using the lowercase version of the name with `_` replaced with `-`.
254 * @param default the default value, must be provided to ensure correct type inference.
255 */
enumOptionnull256 internal inline fun <reified T : Enum<T>> ParameterHolder.enumOption(
257 vararg names: String,
258 help: String,
259 noinline enumValueHelpGetter: (T) -> String,
260 noinline key: (T) -> String = { it.name.lowercase().replace("_", "-") },
261 default: T,
262 ): OptionWithValues<T, T, T> {
263 // Create a choice mapping from option to enum value using the `key` function.
264 val enumValues = enumValues<T>()
265 return nonInlineEnumOption(names, enumValues, help, enumValueHelpGetter, key, default)
266 }
267
268 /**
269 * Extract the majority of the work into a non-inline function to avoid it creating too much bloat
270 * in the call sites.
271 */
nonInlineEnumOptionnull272 internal fun <T : Enum<T>> ParameterHolder.nonInlineEnumOption(
273 names: Array<out String>,
274 enumValues: Array<T>,
275 help: String,
276 enumValueHelpGetter: (T) -> String,
277 key: (T) -> String,
278 default: T
279 ): OptionWithValues<T, T, T> {
280 // Filter out any enum values that do not provide any help.
281 val optionToValue = enumValues.filter { enumValueHelpGetter(it) != "" }.associateBy { key(it) }
282
283 // Get the help representation of the default value.
284 val defaultForHelp = key(default)
285
286 val constructedHelp = buildString {
287 append(help)
288 append(HARD_NEWLINE)
289 for (enumValue in optionToValue.values) {
290 val value = key(enumValue)
291 // This must match the pattern used in MetalavaHelpFormatter.styleEnumHelpTextIfNeeded
292 // which is used to deconstruct this.
293 append(constructStyleableChoiceOption(value))
294 append(" - ")
295 append(enumValueHelpGetter(enumValue))
296 append(HARD_NEWLINE)
297 }
298 }
299
300 return option(names = names, help = constructedHelp)
301 .choice(optionToValue)
302 .default(default, defaultForHelp = defaultForHelp)
303 }
304
305 /**
306 * Construct a styleable choice option.
307 *
308 * This prefixes and suffixes the choice option with `**` (like Markdown) so that they can be found
309 * in the help text using [deconstructStyleableChoiceOption] and replaced with actual styling
310 * sequences if needed.
311 */
constructStyleableChoiceOptionnull312 private fun constructStyleableChoiceOption(value: String) = "$HARD_NEWLINE**$value**"
313
314 /**
315 * A regular expression that will match choice options created using
316 * [constructStyleableChoiceOption].
317 */
318 private val deconstructStyleableChoiceOption = """$HARD_NEWLINE\*\*([^*]+)\*\*""".toRegex()
319
320 /**
321 * Replace the choice option (i.e. the value passed to [constructStyleableChoiceOption]) with the
322 * result of calling the [transformer] on it.
323 *
324 * This must only be called on a [MatchResult] found using the [deconstructStyleableChoiceOption]
325 * regular expression.
326 */
327 private fun MatchResult.replaceChoiceOption(
328 builder: StringBuilder,
329 transformer: (String) -> String
330 ) {
331 val group = groups[1] ?: throw IllegalStateException("group 1 not found in $this")
332 val choiceOption = group.value
333 val replacementText = transformer(choiceOption)
334 // Replace the choice option and the surrounding style markers but not the leading NEL.
335 builder.replace(range.first + 1, range.last + 1, replacementText)
336 }
337
338 /**
339 * Scan [help] using [deconstructStyleableChoiceOption] for enum value help created using
340 * [constructStyleableChoiceOption] and if it was found then style it using the [terminal].
341 *
342 * If an enum value is found that matches the value of the [HelpFormatter.Tags.DEFAULT] tag in
343 * [tags] then annotate is as the default and remove the tag, so it is not added by the default help
344 * formatter.
345 */
styleEnumHelpTextIfNeedednull346 internal fun styleEnumHelpTextIfNeeded(
347 help: String,
348 tags: MutableMap<String, String>,
349 terminal: Terminal
350 ): String {
351 val defaultForHelp = tags[HelpFormatter.Tags.DEFAULT]
352
353 // Find all styleable choice options in the help text. If there are none then just return
354 // and use the default rendering.
355 val matchResults = deconstructStyleableChoiceOption.findAll(help).toList()
356 if (matchResults.isEmpty()) {
357 return help
358 }
359
360 val styledHelp = buildString {
361 append(help)
362
363 // Iterate over the matches in reverse order replacing any styleable choice options
364 // with styled versions.
365 for (matchResult in matchResults.reversed()) {
366 matchResult.replaceChoiceOption(this) { optionValue ->
367 val styledOptionValue = terminal.bold(optionValue)
368 if (optionValue == defaultForHelp) {
369 // Remove the default value from the tags so it is not included in the help.
370 tags.remove(HelpFormatter.Tags.DEFAULT)
371
372 "$styledOptionValue (default)"
373 } else {
374 styledOptionValue
375 }
376 }
377 }
378 }
379
380 return styledHelp
381 }
382
383 /**
384 * Extension method that allows a transformation to be provided to a Clikt option that will be
385 * applied after Clikt has processed, transformed (including applying defaults) and validated the
386 * value, but before it is returned.
387 */
mapnull388 fun <I, O> OptionDelegate<I>.map(transform: (I) -> O): OptionDelegate<O> {
389 return PostTransformDelegate(this, transform)
390 }
391
392 /**
393 * An [OptionDelegate] that delegates to another [OptionDelegate] and applies a transformation to
394 * the value it returns.
395 */
396 private class PostTransformDelegate<I, O>(
397 val delegate: OptionDelegate<I>,
398 val transform: (I) -> O,
<lambda>null399 ) : OptionDelegate<O>, GroupableOption by delegate {
400
401 override val value: O
402 get() = transform(delegate.value)
403
404 override fun provideDelegate(
405 thisRef: ParameterHolder,
406 prop: KProperty<*>
407 ): ReadOnlyProperty<ParameterHolder, O> {
408 // Make sure that the wrapped option has registered itself properly.
409 val providedDelegate = delegate.provideDelegate(thisRef, prop)
410 check(providedDelegate == delegate) {
411 "expected $delegate to return itself but it returned $providedDelegate"
412 }
413
414 // This is the delegate.
415 return this
416 }
417 }
418
419 /** A block that performs a side effect when provide a value */
420 typealias SideEffectAction = OptionCallTransformContext.(String) -> Unit
421
422 /** An option that simply performs a [SideEffectAction] */
423 typealias SideEffectOption = OptionWithValues<Unit, Unit, Unit>
424
425 /** Get the [SideEffectAction] (which is stored in [OptionWithValues.transformValue]). */
426 val SideEffectOption.action: SideEffectAction
427 get() = transformValue
428
429 /**
430 * Create a special option that performs a side effect.
431 *
432 * @param names names of the option.
433 * @param help the help for the option.
434 * @param action the action to perform, is passed the value associated with the option and is run
435 * within a [OptionCallTransformContext] context.
436 */
ParameterHoldernull437 fun ParameterHolder.sideEffectOption(
438 vararg names: String,
439 help: String,
440 action: SideEffectAction,
441 ): SideEffectOption {
442 return option(names = names, help = help)
443 .copy(
444 // Perform the side effect when transforming the value.
445 transformValue = { this.action(it) },
446 transformEach = {},
447 transformAll = {},
448 validator = {}
449 )
450 }
451
452 /**
453 * Create a composite side effect option.
454 *
455 * This option will allow the individual options to be interleaved together and will ensure that the
456 * side effects are applied in the order they appear on the command line. Adding the options
457 * individually would cause them to be separated into groups and each group processed in order which
458 * would mean the side effects were applied in a different order.
459 *
460 * The resulting option will still be displayed as multiple separate options in the help.
461 */
ParameterHoldernull462 fun ParameterHolder.compositeSideEffectOption(
463 options: List<SideEffectOption>,
464 ): OptionDelegate<Unit> {
465 val optionByName =
466 options
467 .asSequence()
468 .flatMap { option -> option.names.map { it to option }.asSequence() }
469 .toMap()
470 val names = optionByName.keys.toTypedArray()
471 val help = constructCompositeOptionHelp(optionByName.values.map { it.optionHelp })
472 return sideEffectOption(
473 names = names,
474 help = help,
475 action = {
476 val option = optionByName[name]!!
477 val action = option.action
478 this.action(it)
479 }
480 )
481 }
482
483 /**
484 * A marker string that if present at the start of an options help will cause that option to be
485 * split into separate options, one for each name in the [Option.names].
486 *
487 * See [constructCompositeOptionHelp]
488 */
489 private const val COMPOSITE_OPTION = "\$COMPOSITE-OPTION\$\n"
490
491 /** Separator of help for each item in the string returned by [constructCompositeOptionHelp]. */
492 private const val COMPOSITE_SEPARATOR = "\n\$COMPOSITE-SEPARATOR\$\n"
493
494 /**
495 * Construct the help for a composite option, which is an option that has multiple names and is
496 * treated like a single option for the purposes of parsing but which needs to be displayed as a
497 * number of separate options.
498 *
499 * @param individualOptionHelp must have an entry for every name in an option's set of names and it
500 * must be in the same order as that set.
501 */
constructCompositeOptionHelpnull502 private fun constructCompositeOptionHelp(individualOptionHelp: List<String>) =
503 "$COMPOSITE_OPTION${individualOptionHelp.joinToString(COMPOSITE_SEPARATOR)}"
504
505 /**
506 * Checks to see if an [Option] is actually a composite option which needs splitting into separate
507 * options for help formatting.
508 */
509 internal fun Option.isCompositeOption(): Boolean = help.startsWith(COMPOSITE_OPTION)
510
511 /**
512 * Deconstructs the help created by [constructCompositeOptionHelp] checking to make sure that there
513 * is one item for every [Option.names].
514 */
515 internal fun Option.deconstructCompositeHelp(): List<String> {
516 val lines = help.removePrefix(COMPOSITE_OPTION).split(COMPOSITE_SEPARATOR)
517 if (lines.size != names.size) {
518 throw IllegalStateException(
519 "Expected ${names.size} blocks of help but found ${lines.size} in ${help}"
520 )
521 }
522 return lines
523 }
524
525 /** Decompose the [Option] into multiple separate options. */
decomposenull526 internal fun Option.decompose(): Sequence<Option> {
527 val lines = deconstructCompositeHelp()
528 return names.asSequence().mapIndexed { i, name ->
529 val metavar = if (name.endsWith("-category")) "<name>" else "<id>"
530 val help = lines[i]
531 copy(names = setOf(name), metavar = metavar, help = help)
532 }
533 }
534
535 /**
536 * Clikt does not allow `:` in option names but Metalava uses that for creating structured option
537 * names, e.g. --part1:part2:part3.
538 *
539 * This method can be used to circumvent the built-in check and use a custom check that allows for
540 * structure option names. Call it at the end of the `option(...)....allowStructureOptionName()`
541 * call chain.
542 */
allowStructuredOptionNamenull543 fun <T> OptionWithValues<T, *, *>.allowStructuredOptionName(): OptionDelegate<T> {
544 return StructuredOptionName(this)
545 }
546
547 /** Allows the same format for option names as Clikt with the addition of the ':' character. */
checkStructuredOptionNamesnull548 private fun checkStructuredOptionNames(names: Set<String>) {
549 val invalidName = names.find { !it.matches(Regex("""[\-@/+]{1,2}[\w\-_:]+""")) }
550 require(invalidName == null) { "Invalid option name \"$invalidName\"" }
551 }
552
553 /** Circumvents the usual Clikt name format check and substitutes its own name format check. */
554 class StructuredOptionName<T>(private val delegate: OptionDelegate<T>) :
<lambda>null555 OptionDelegate<T> by delegate {
556
557 override fun provideDelegate(
558 thisRef: ParameterHolder,
559 prop: KProperty<*>
560 ): ReadOnlyProperty<ParameterHolder, T> {
561 // If no names are provided then delegate this to the built-in method to infer the option
562 // name as that name is guaranteed not to contain a ':'.
563 if (names.isEmpty()) {
564 return delegate.provideDelegate(thisRef, prop)
565 }
566 require(secondaryNames.isEmpty()) {
567 "Secondary option names are only allowed on flag options."
568 }
569 checkStructuredOptionNames(names)
570 thisRef.registerOption(delegate)
571 return this
572 }
573 }
574