• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 option to a [File] that represents an existing directory. */
RawOptionnull53 fun RawOption.existingDir(): NullableOption<File, File> {
54     return fileConversion(::stringToExistingDir)
55 }
56 
57 /** Convert the argument to a [File] that represents an existing directory. */
RawArgumentnull58 fun RawArgument.existingDir(): ProcessedArgument<File, File> {
59     return fileConversion(::stringToExistingDir)
60 }
61 
62 /** Convert the option to a [File] that represents a new file. */
newFilenull63 fun RawOption.newFile(): NullableOption<File, File> {
64     return fileConversion(::stringToNewFile)
65 }
66 
67 /** Convert the option to a [File] that represents a new directory. */
newDirnull68 fun RawOption.newDir(): NullableOption<File, File> {
69     return fileConversion(::stringToNewDir)
70 }
71 
72 /** Convert the argument to a [File] that represents a new file. */
newFilenull73 fun RawArgument.newFile(): ProcessedArgument<File, File> {
74     return fileConversion(::stringToNewFile)
75 }
76 
77 /** Convert the argument to a [File] that represents a new directory. */
newDirnull78 fun RawArgument.newDir(): ProcessedArgument<File, File> {
79     return fileConversion(::stringToNewDir)
80 }
81 
82 /** Convert the option to a [File] that represents a new or existing file. */
newOrExistingFilenull83 fun RawOption.newOrExistingFile(): NullableOption<File, File> {
84     return fileConversion(::stringToNewOrExistingFile)
85 }
86 
87 /** Convert the option to a [File] using the supplied conversion function.. */
fileConversionnull88 private fun RawOption.fileConversion(conversion: (String) -> File): NullableOption<File, File> {
89     return convert({ localization.pathMetavar() }, CompletionCandidates.Path) { str ->
90         try {
91             conversion(str)
92         } catch (e: MetalavaCliException) {
93             e.message?.let { fail(it) } ?: throw e
94         }
95     }
96 }
97 
98 /** Convert the argument to a [File] using the supplied conversion function. */
fileConversionnull99 fun RawArgument.fileConversion(conversion: (String) -> File): ProcessedArgument<File, File> {
100     return convert(CompletionCandidates.Path) { str ->
101         try {
102             conversion(str)
103         } catch (e: MetalavaCliException) {
104             e.message?.let { fail(it) } ?: throw e
105         }
106     }
107 }
108 
109 /**
110  * Converts a path to a [File] that represents the absolute path, with the following special
111  * behavior:
112  * - "~" will be expanded into the home directory path.
113  * - If the given path starts with "@", it'll be converted into "@" + [file's absolute path]
114  */
fileForPathInnernull115 internal fun fileForPathInner(path: String): File {
116     // java.io.File doesn't automatically handle ~/ -> home directory expansion.
117     // This isn't necessary when metalava is run via the command line driver
118     // (since shells will perform this expansion) but when metalava is run
119     // directly, not from a shell.
120     if (path.startsWith("~/")) {
121         val home = System.getProperty("user.home") ?: return File(path)
122         return File(home + path.substring(1))
123     } else if (path.startsWith("@")) {
124         return File("@" + File(path.substring(1)).absolutePath)
125     }
126 
127     return File(path).absoluteFile
128 }
129 
130 /**
131  * Convert a string representing an existing directory to a [File].
132  *
133  * This will fail if:
134  * * The file is not a regular directory.
135  */
stringToExistingDirnull136 internal fun stringToExistingDir(value: String): File {
137     val file = fileForPathInner(value)
138     if (!file.isDirectory) {
139         cliError("$file is not a directory")
140     }
141     return file
142 }
143 
144 /**
145  * Convert a string representing a new directory to a [File].
146  *
147  * This will fail if:
148  * * the directory exists and cannot be deleted.
149  * * the directory cannot be created.
150  */
stringToNewDirnull151 internal fun stringToNewDir(value: String): File {
152     val output = fileForPathInner(value)
153     val ok =
154         if (output.exists()) {
155             if (output.isDirectory) {
156                 output.deleteRecursively()
157             }
158             if (output.exists()) {
159                 true
160             } else {
161                 output.mkdir()
162             }
163         } else {
164             output.mkdirs()
165         }
166     if (!ok) {
167         cliError("Could not create $output")
168     }
169 
170     return output
171 }
172 
173 /**
174  * Convert a string representing an existing file to a [File].
175  *
176  * This will fail if:
177  * * The file is not a regular file.
178  */
stringToExistingFilenull179 internal fun stringToExistingFile(value: String): File {
180     val file = fileForPathInner(value)
181     if (!file.isFile) {
182         cliError("$file is not a file")
183     }
184     return file
185 }
186 
187 /**
188  * Convert a string representing a new file to a [File].
189  *
190  * This will fail if:
191  * * the file is a directory.
192  * * the file exists and cannot be deleted.
193  * * the parent directory does not exist, and cannot be created.
194  */
stringToNewFilenull195 internal fun stringToNewFile(value: String): File {
196     val output = fileForPathInner(value)
197 
198     if (output.exists()) {
199         if (output.isDirectory) {
200             cliError("$output is a directory")
201         }
202         val deleted = output.delete()
203         if (!deleted) {
204             cliError("Could not delete previous version of $output")
205         }
206     } else if (output.parentFile != null && !output.parentFile.exists()) {
207         val ok = output.parentFile.mkdirs()
208         if (!ok) {
209             cliError("Could not create ${output.parentFile}")
210         }
211     }
212 
213     return output
214 }
215 
216 /**
217  * Convert a string representing a new or existing file to a [File].
218  *
219  * This will fail if:
220  * * the file is a directory.
221  * * the parent directory does not exist, and cannot be created.
222  */
stringToNewOrExistingFilenull223 internal fun stringToNewOrExistingFile(value: String): File {
224     val file = fileForPathInner(value)
225     if (!file.exists()) {
226         val parentFile = file.parentFile
227         if (parentFile != null && !parentFile.isDirectory) {
228             val ok = parentFile.mkdirs()
229             if (!ok) {
230                 cliError("Could not create $parentFile")
231             }
232         }
233     }
234     return file
235 }
236 
237 // Unicode Next Line (NEL) character which forces Clikt to insert a new line instead of just
238 // collapsing the `\n` into adjacent spaces. Acts like an HTML <br/>.
239 const val HARD_NEWLINE = "\u0085"
240 
241 // Two consecutive newline characters will result in a blank line in the Clikt formatted output.
242 const val BLANK_LINE = "\n\n"
243 
244 /**
245  * Create a property delegate for an enum.
246  *
247  * This will generate help text that:
248  * * uses lower case version of the enum value name (with `_` replaced with `-`) as the value to
249  *   supply on the command line.
250  * * formats the help for each enum value in its own block separated from surrounding blocks by
251  *   blank lines.
252  * * will tag the default enum value in the help.
253  *
254  * @param names the possible names for the option that can be used on the command line.
255  * @param help the help for the option, does not need to include information about the default or
256  *   the individual options as they will be added automatically.
257  * @param enumValueHelpGetter given an enum value return the help for it.
258  * @param key given an enum value return the value that must be specified on the command line. This
259  *   is used to create a bidirectional mapping so that command line option can be mapped to the enum
260  *   value and the default enum value mapped back to the default command line option. Defaults to
261  *   using the lowercase version of the name with `_` replaced with `-`.
262  * @param default the default value, must be provided to ensure correct type inference.
263  */
enumOptionnull264 internal inline fun <reified T : Enum<T>> ParameterHolder.enumOption(
265     vararg names: String,
266     help: String,
267     noinline enumValueHelpGetter: (T) -> String,
268     noinline key: (T) -> String = { it.name.lowercase().replace("_", "-") },
269     default: T,
270 ): OptionWithValues<T, T, T> {
271     // Create a choice mapping from option to enum value using the `key` function.
272     val enumValues = enumValues<T>()
273     return nonInlineEnumOption(names, enumValues, help, enumValueHelpGetter, key, default)
274 }
275 
276 /**
277  * Extract the majority of the work into a non-inline function to avoid it creating too much bloat
278  * in the call sites.
279  */
nonInlineEnumOptionnull280 internal fun <T : Enum<T>> ParameterHolder.nonInlineEnumOption(
281     names: Array<out String>,
282     enumValues: Array<T>,
283     help: String,
284     enumValueHelpGetter: (T) -> String,
285     enumLabelGetter: (T) -> String,
286     default: T
287 ): OptionWithValues<T, T, T> {
288     val labelToEnumValue =
289         enumValues
290             // Filter out any enum values that do not provide any help.
291             .filter { enumValueHelpGetter(it) != "" }
292             // Convert to a map from label to enum value.
293             .associateBy { enumLabelGetter(it) }
294 
295     // Get the help representation of the default value.
296     val defaultForHelp = enumLabelGetter(default)
297 
298     val constructedHelp = buildString {
299         append(help)
300         appendDefinitionListHelp(
301             labelToEnumValue.entries.map { (label, enumValue) ->
302                 label to enumValueHelpGetter(enumValue)
303             }
304         )
305     }
306 
307     return option(names = names, help = constructedHelp)
308         .choice(labelToEnumValue)
309         .default(default, defaultForHelp = defaultForHelp)
310 }
311 
312 /**
313  * Build definition list help.
314  *
315  * @param definitionList is a list of [Pair]s, where [Pair.first] is the term being defined and
316  *   [Pair.second] is the definition of that term.
317  * @param termPrefix the prefix to add before each term being defined, e.g. `* ` to represent a
318  *   bullet list.
319  */
buildDefinitionListHelpnull320 fun buildDefinitionListHelp(
321     definitionList: List<Pair<String, String>>,
322     termPrefix: String = "",
323 ): String {
324     return buildString { appendDefinitionListHelp(definitionList, termPrefix) }
325 }
326 
327 /**
328  * Append help for what is effectively a definition list, e.g. `<dl>...</dl>` in HTML.
329  *
330  * Each entry in the list has a term that is being defined and the definition of that term. If the
331  * terminal supports it then the term will be in bold. The term and definition are separate by ` -
332  * `.
333  *
334  * @param definitionList is a list of [Pair]s, where [Pair.first] is the term being defined and
335  *   [Pair.second] is the definition of that term.
336  * @param termPrefix the prefix to add before each term being defined, e.g. `* ` to represent a
337  *   bullet list.
338  */
appendDefinitionListHelpnull339 private fun StringBuilder.appendDefinitionListHelp(
340     definitionList: List<Pair<String, String>>,
341     termPrefix: String = "",
342 ) {
343     append(BLANK_LINE)
344     for ((term, body) in definitionList) {
345         // This must match the pattern used in MetalavaHelpFormatter.styleEnumHelpTextIfNeeded
346         // which is used to deconstruct this.
347         append(constructStyleableChoiceOption(term, termPrefix))
348         append(" - ")
349         append(body)
350         append(BLANK_LINE)
351     }
352 }
353 
354 /**
355  * Construct a styleable choice option.
356  *
357  * This prefixes and suffixes the choice option with `**` (like Markdown) so that they can be found
358  * in the help text using [deconstructStyleableChoiceOption] and replaced with actual styling
359  * sequences if needed.
360  */
constructStyleableChoiceOptionnull361 private fun constructStyleableChoiceOption(value: String, prefix: String = "") =
362     "$BLANK_LINE$prefix**$value**"
363 
364 /**
365  * A regular expression that will match choice options created using
366  * [constructStyleableChoiceOption].
367  */
368 private val deconstructStyleableChoiceOption = """$BLANK_LINE(.*?)(\*\*([^*]+)\*\*)""".toRegex()
369 
370 /**
371  * The index of the group in [deconstructStyleableChoiceOption] that matches the prefix provided to
372  * [constructStyleableChoiceOption].
373  */
374 private const val PREFIX_GROUP_INDEX = 1
375 
376 /**
377  * The index of the group in [deconstructStyleableChoiceOption] that must be replaced by
378  * [replaceChoiceOption].
379  */
380 private const val REPLACEMENT_GROUP_INDEX = PREFIX_GROUP_INDEX + 1
381 
382 /**
383  * The index of the group in [deconstructStyleableChoiceOption] that contains the label that will be
384  * transformed by [replaceChoiceOption].
385  */
386 private const val LABEL_GROUP_INDEX = REPLACEMENT_GROUP_INDEX + 1
387 
388 /**
389  * Replace the choice option (i.e. the value passed to [constructStyleableChoiceOption]) with the
390  * result of calling the [transformer] on it.
391  *
392  * This must only be called on a [MatchResult] found using the [deconstructStyleableChoiceOption]
393  * regular expression.
394  */
395 private fun MatchResult.replaceChoiceOption(
396     builder: StringBuilder,
397     transformer: (String) -> String
398 ) {
399     // Get the text for the label of the choice option.
400     val labelGroup =
401         groups[LABEL_GROUP_INDEX] ?: error("label group $LABEL_GROUP_INDEX not found in $this")
402     val label = labelGroup.value
403 
404     // Transform the label.
405     val transformedLabel = transformer(label)
406 
407     // Replace the label and the surrounding style markers but not the leading blank line or prefix
408     // with the transformed label.
409     val replacementGroup =
410         groups[REPLACEMENT_GROUP_INDEX]
411             ?: error("replacement group $REPLACEMENT_GROUP_INDEX not found in $this")
412     builder.replace(replacementGroup.range.first, replacementGroup.range.last + 1, transformedLabel)
413 }
414 
415 /**
416  * Scan [help] using [deconstructStyleableChoiceOption] for enum value help created using
417  * [constructStyleableChoiceOption] and if it was found then style it using the [terminal].
418  *
419  * If an enum value is found that matches the value of the [HelpFormatter.Tags.DEFAULT] tag in
420  * [tags] then annotate is as the default and remove the tag, so it is not added by the default help
421  * formatter.
422  */
styleEnumHelpTextIfNeedednull423 internal fun styleEnumHelpTextIfNeeded(
424     help: String,
425     tags: MutableMap<String, String>,
426     terminal: Terminal
427 ): String {
428     val defaultForHelp = tags[HelpFormatter.Tags.DEFAULT]
429 
430     // Find all styleable choice options in the help text. If there are none then just return
431     // and use the default rendering.
432     val matchResults = deconstructStyleableChoiceOption.findAll(help).toList()
433     if (matchResults.isEmpty()) {
434         return help
435     }
436 
437     val styledHelp = buildString {
438         append(help)
439 
440         // Iterate over the matches in reverse order replacing any styleable choice options
441         // with styled versions.
442         for (matchResult in matchResults.reversed()) {
443             matchResult.replaceChoiceOption(this) { optionValue ->
444                 val styledOptionValue = terminal.bold(optionValue)
445                 if (optionValue == defaultForHelp) {
446                     // Remove the default value from the tags so it is not included in the help.
447                     tags.remove(HelpFormatter.Tags.DEFAULT)
448 
449                     "$styledOptionValue (default)"
450                 } else {
451                     styledOptionValue
452                 }
453             }
454         }
455     }
456 
457     return styledHelp
458 }
459 
460 /**
461  * Extension method that allows a transformation to be provided to a Clikt option that will be
462  * applied after Clikt has processed, transformed (including applying defaults) and validated the
463  * value, but before it is returned.
464  */
mapnull465 fun <I, O> OptionDelegate<I>.map(transform: (I) -> O): OptionDelegate<O> {
466     return PostTransformDelegate(this, transform)
467 }
468 
469 /**
470  * An [OptionDelegate] that delegates to another [OptionDelegate] and applies a transformation to
471  * the value it returns.
472  */
473 private class PostTransformDelegate<I, O>(
474     val delegate: OptionDelegate<I>,
475     val transform: (I) -> O,
<lambda>null476 ) : OptionDelegate<O>, GroupableOption by delegate {
477 
478     override val value: O
479         get() = transform(delegate.value)
480 
481     override fun provideDelegate(
482         thisRef: ParameterHolder,
483         prop: KProperty<*>
484     ): ReadOnlyProperty<ParameterHolder, O> {
485         // Make sure that the wrapped option has registered itself properly.
486         val providedDelegate = delegate.provideDelegate(thisRef, prop)
487         check(providedDelegate == delegate) {
488             "expected $delegate to return itself but it returned $providedDelegate"
489         }
490 
491         // This is the delegate.
492         return this
493     }
494 }
495 
496 /** A block that performs a side effect when provide a value */
497 typealias SideEffectAction = OptionCallTransformContext.(String) -> Unit
498 
499 /** An option that simply performs a [SideEffectAction] */
500 typealias SideEffectOption = OptionWithValues<Unit, Unit, Unit>
501 
502 /** Get the [SideEffectAction] (which is stored in [OptionWithValues.transformValue]). */
503 val SideEffectOption.action: SideEffectAction
504     get() = transformValue
505 
506 /**
507  * Create a special option that performs a side effect.
508  *
509  * @param names names of the option.
510  * @param help the help for the option.
511  * @param action the action to perform, is passed the value associated with the option and is run
512  *   within a [OptionCallTransformContext] context.
513  */
ParameterHoldernull514 fun ParameterHolder.sideEffectOption(
515     vararg names: String,
516     help: String,
517     action: SideEffectAction,
518 ): SideEffectOption {
519     return option(names = names, help = help)
520         .copy(
521             // Perform the side effect when transforming the value.
522             transformValue = { this.action(it) },
523             transformEach = {},
524             transformAll = {},
525             validator = {}
526         )
527 }
528 
529 /**
530  * Create a composite side effect option.
531  *
532  * This option will allow the individual options to be interleaved together and will ensure that the
533  * side effects are applied in the order they appear on the command line. Adding the options
534  * individually would cause them to be separated into groups and each group processed in order which
535  * would mean the side effects were applied in a different order.
536  *
537  * The resulting option will still be displayed as multiple separate options in the help.
538  */
ParameterHoldernull539 fun ParameterHolder.compositeSideEffectOption(
540     options: List<SideEffectOption>,
541 ): OptionDelegate<Unit> {
542     val optionByName =
543         options
544             .asSequence()
545             .flatMap { option -> option.names.map { it to option }.asSequence() }
546             .toMap()
547     val names = optionByName.keys.toTypedArray()
548     val help = constructCompositeOptionHelp(optionByName.values.map { it.optionHelp })
549     return sideEffectOption(
550         names = names,
551         help = help,
552         action = {
553             val option = optionByName[name]!!
554             val action = option.action
555             this.action(it)
556         }
557     )
558 }
559 
560 /**
561  * A marker string that if present at the start of an options help will cause that option to be
562  * split into separate options, one for each name in the [Option.names].
563  *
564  * See [constructCompositeOptionHelp]
565  */
566 private const val COMPOSITE_OPTION = "\$COMPOSITE-OPTION\$\n"
567 
568 /** Separator of help for each item in the string returned by [constructCompositeOptionHelp]. */
569 private const val COMPOSITE_SEPARATOR = "\n\$COMPOSITE-SEPARATOR\$\n"
570 
571 /**
572  * Construct the help for a composite option, which is an option that has multiple names and is
573  * treated like a single option for the purposes of parsing but which needs to be displayed as a
574  * number of separate options.
575  *
576  * @param individualOptionHelp must have an entry for every name in an option's set of names and it
577  *   must be in the same order as that set.
578  */
constructCompositeOptionHelpnull579 private fun constructCompositeOptionHelp(individualOptionHelp: List<String>) =
580     "$COMPOSITE_OPTION${individualOptionHelp.joinToString(COMPOSITE_SEPARATOR)}"
581 
582 /**
583  * Checks to see if an [Option] is actually a composite option which needs splitting into separate
584  * options for help formatting.
585  */
586 internal fun Option.isCompositeOption(): Boolean = help.startsWith(COMPOSITE_OPTION)
587 
588 /**
589  * Deconstructs the help created by [constructCompositeOptionHelp] checking to make sure that there
590  * is one item for every [Option.names].
591  */
592 internal fun Option.deconstructCompositeHelp(): List<String> {
593     val lines = help.removePrefix(COMPOSITE_OPTION).split(COMPOSITE_SEPARATOR)
594     if (lines.size != names.size) {
595         throw IllegalStateException(
596             "Expected ${names.size} blocks of help but found ${lines.size} in ${help}"
597         )
598     }
599     return lines
600 }
601 
602 /** Decompose the [Option] into multiple separate options. */
decomposenull603 internal fun Option.decompose(): Sequence<Option> {
604     val lines = deconstructCompositeHelp()
605     return names.asSequence().mapIndexed { i, name ->
606         val metavar = if (name.endsWith("-category")) "<name>" else "<id>"
607         val help = lines[i]
608         copy(names = setOf(name), metavar = metavar, help = help)
609     }
610 }
611 
612 /**
613  * Clikt does not allow `:` in option names but Metalava uses that for creating structured option
614  * names, e.g. --part1:part2:part3.
615  *
616  * This method can be used to circumvent the built-in check and use a custom check that allows for
617  * structure option names. Call it at the end of the `option(...)....allowStructureOptionName()`
618  * call chain.
619  */
allowStructuredOptionNamenull620 fun <T> OptionWithValues<T, *, *>.allowStructuredOptionName(): OptionDelegate<T> {
621     return StructuredOptionName(this)
622 }
623 
624 /** Allows the same format for option names as Clikt with the addition of the ':' character. */
checkStructuredOptionNamesnull625 private fun checkStructuredOptionNames(names: Set<String>) {
626     val invalidName = names.find { !it.matches(Regex("""[\-@/+]{1,2}[\w\-_:]+""")) }
627     require(invalidName == null) { "Invalid option name \"$invalidName\"" }
628 }
629 
630 /** Circumvents the usual Clikt name format check and substitutes its own name format check. */
631 class StructuredOptionName<T>(private val delegate: OptionDelegate<T>) :
<lambda>null632     OptionDelegate<T> by delegate {
633 
634     override fun provideDelegate(
635         thisRef: ParameterHolder,
636         prop: KProperty<*>
637     ): ReadOnlyProperty<ParameterHolder, T> {
638         // If no names are provided then delegate this to the built-in method to infer the option
639         // name as that name is guaranteed not to contain a ':'.
640         if (names.isEmpty()) {
641             return delegate.provideDelegate(thisRef, prop)
642         }
643         require(secondaryNames.isEmpty()) {
644             "Secondary option names are only allowed on flag options."
645         }
646         checkStructuredOptionNames(names)
647         thisRef.registerOption(delegate)
648         return this
649     }
650 }
651