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