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