1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tools.metalava.cli.common
18 
19 import com.android.tools.metalava.DefaultReporter
20 import com.android.tools.metalava.DefaultReporterEnvironment
21 import com.android.tools.metalava.ReporterEnvironment
22 import com.android.tools.metalava.reporter.ERROR_WHEN_NEW_SUFFIX
23 import com.android.tools.metalava.reporter.IssueConfiguration
24 import com.android.tools.metalava.reporter.Issues
25 import com.android.tools.metalava.reporter.Reporter
26 import com.android.tools.metalava.reporter.Severity
27 import com.github.ajalt.clikt.parameters.groups.OptionGroup
28 import com.github.ajalt.clikt.parameters.options.default
29 import com.github.ajalt.clikt.parameters.options.flag
30 import com.github.ajalt.clikt.parameters.options.option
31 import com.github.ajalt.clikt.parameters.types.int
32 import com.github.ajalt.clikt.parameters.types.restrictTo
33 import java.io.File
34 
35 const val ARG_ERROR = "--error"
36 const val ARG_ERROR_WHEN_NEW = "--error-when-new"
37 const val ARG_WARNING = "--warning"
38 const val ARG_HIDE = "--hide"
39 const val ARG_ERROR_CATEGORY = "--error-category"
40 const val ARG_ERROR_WHEN_NEW_CATEGORY = "--error-when-new-category"
41 const val ARG_WARNING_CATEGORY = "--warning-category"
42 const val ARG_HIDE_CATEGORY = "--hide-category"
43 
44 const val ARG_WARNINGS_AS_ERRORS = "--warnings-as-errors"
45 
46 const val ARG_REPORT_EVEN_IF_SUPPRESSED = "--report-even-if-suppressed"
47 
48 /** The name of the group, can be used in help text to refer to the options in this group. */
49 const val REPORTING_OPTIONS_GROUP = "Issue Reporting"
50 
51 class IssueReportingOptions(
52     reporterEnvironment: ReporterEnvironment = DefaultReporterEnvironment(),
53     commonOptions: CommonOptions = CommonOptions(),
54 ) :
55     OptionGroup(
56         name = REPORTING_OPTIONS_GROUP,
57         help =
58             """
59             Options that control which issues are reported, the severity of the reports, how, when
60             and where they are reported.
61 
62             See `metalava help issues` for more help including a table of the available issues and
63             their category and default severity.
64         """
65                 .trimIndent()
66     ) {
67 
68     /** The [IssueConfiguration] that is configured by these options. */
69     val issueConfiguration = IssueConfiguration()
70 
71     /**
72      * The [Reporter] that is used to report issues encountered while parsing these options.
73      *
74      * A slight complexity is that this [Reporter] and its [IssueConfiguration] are both modified
75      * and used during the process of processing the options.
76      */
77     internal val bootstrapReporter: Reporter =
78         DefaultReporter(
79             reporterEnvironment,
80             issueConfiguration,
81         )
82 
83     init {
84         // Create a Clikt option for handling the issue options and updating them as a side effect.
85         // This needs to be a single option for handling all the issue options in one go because the
86         // order of processing them matters and Clikt will collate all the values for all the
87         // options together before processing them so something like this would never work if they
88         // were treated as separate options.
89         //     --hide Foo --error Bar --hide Bar --error Foo
90         //
91         // When processed immediately that is equivalent to:
92         //     --hide Bar --error Foo
93         //
94         // However, when processed after grouping they would be equivalent to one of the following
95         // depending on which was processed last:
96         //     --hide Foo,Bar
97         //     --error Foo,Bar
98         //
99         // Instead, this creates a single Clikt option to handle all the issue options but they are
100         // still collated before processing so if they are interleaved with other options whose
101         // parsing makes use of the `reporter` then there is the potential for a change in behavior.
102         // However, currently it does not look as though that is the case except for reporting
103         // issues with deprecated options which is tested.
104         //
105         // Having a single Clikt option with lots of different option names does make the help hard
106         // to read as it produces a single line with all the option names on it. So, this uses a
107         // mechanism that will cause `MetalavaHelpFormatter` to split the option into multiple
108         // separate options purely for help purposes.
109         val issueOption =
110             compositeSideEffectOption(
111                 // Create one side effect option per label.
<lambda>null112                 ConfigLabel.values().map {
113                     sideEffectOption(it.optionName, help = it.help) {
114                         // if `--hide id1,id2` was supplied on the command line then this will split
115                         // it into
116                         // ["id1", "id2"]
117                         val values = it.split(",")
118 
119                         // Get the label from the name of the option.
120                         val label = ConfigLabel.fromOptionName(name)
121 
122                         // Update the configuration immediately
123                         values.forEach {
124                             label.setAspectForId(bootstrapReporter, issueConfiguration, it.trim())
125                         }
126                     }
127                 }
128             )
129 
130         // Register the option so that Clikt will process it.
131         registerOption(issueOption)
132     }
133 
134     private val warningsAsErrors: Boolean by
135         option(
136                 ARG_WARNINGS_AS_ERRORS,
137                 help =
138                     """
139                         Promote all warnings to errors.
140                     """
141                         .trimIndent()
142             )
143             .flag()
144 
145     /** Writes a list of all errors, even if they were suppressed in baseline or via annotation. */
146     private val reportEvenIfSuppressedFile by
147         option(
148                 ARG_REPORT_EVEN_IF_SUPPRESSED,
149                 help =
150                     """
151                         Write all issues into the given file, even if suppressed (via annotation or
152                         baseline) but not if hidden (by '$ARG_HIDE' or '$ARG_HIDE_CATEGORY').
153                     """
154                         .trimIndent(),
155             )
156             .newOrExistingFile()
157 
158     /** When non-0, metalava repeats all the errors at the end of the run, at most this many. */
159     val repeatErrorsMax by
160         option(
161                 ARG_REPEAT_ERRORS_MAX,
162                 metavar = "<n>",
163                 help = """When specified, repeat at most N errors before finishing."""
164             )
165             .int()
166             .restrictTo(min = 0)
167             .default(0)
168 
169     internal val reporterConfig by
<lambda>null170         lazy(LazyThreadSafetyMode.NONE) {
171             val reportEvenIfSuppressedWriter = reportEvenIfSuppressedFile?.printWriter()
172 
173             DefaultReporter.Config(
174                 warningsAsErrors = warningsAsErrors,
175                 terminal = commonOptions.terminal,
176                 reportEvenIfSuppressedWriter = reportEvenIfSuppressedWriter,
177             )
178         }
179 }
180 
181 /** The different configurable aspects of [IssueConfiguration]. */
182 private enum class ConfigurableAspect {
183     /** A single issue needs configuring. */
184     ISSUE {
setAspectSeverityForIdnull185         override fun setAspectSeverityForId(
186             reporter: Reporter,
187             configuration: IssueConfiguration,
188             optionName: String,
189             severity: Severity,
190             id: String
191         ) {
192             val issue =
193                 Issues.findIssueById(id)
194                     ?: Issues.findIssueByIdIgnoringCase(id)?.also {
195                         reporter.report(
196                             Issues.DEPRECATED_OPTION,
197                             null as File?,
198                             "Case-insensitive issue matching is deprecated, use " +
199                                 "$optionName ${it.name} instead of $optionName $id"
200                         )
201                     }
202                         ?: throw MetalavaCliException("Unknown issue id: '$optionName' '$id'")
203 
204             configuration.setSeverity(issue, severity)
205         }
206     },
207     /** A whole category of issues needs configuring. */
208     CATEGORY {
setAspectSeverityForIdnull209         override fun setAspectSeverityForId(
210             reporter: Reporter,
211             configuration: IssueConfiguration,
212             optionName: String,
213             severity: Severity,
214             id: String
215         ) {
216             val issues =
217                 Issues.findCategoryById(id)?.let { Issues.findIssuesByCategory(it) }
218                     ?: throw MetalavaCliException("Unknown category: $optionName $id")
219 
220             issues.forEach { configuration.setSeverity(it, severity) }
221         }
222     };
223 
224     /** Configure the [IssueConfiguration] appropriately. */
setAspectSeverityForIdnull225     abstract fun setAspectSeverityForId(
226         reporter: Reporter,
227         configuration: IssueConfiguration,
228         optionName: String,
229         severity: Severity,
230         id: String
231     )
232 }
233 
234 /** The different labels that can be used on the command line. */
235 private enum class ConfigLabel(
236     val optionName: String,
237     /** The [Severity] which this label corresponds to. */
238     val severity: Severity,
239     val aspect: ConfigurableAspect,
240     val help: String
241 ) {
242     ERROR(
243         ARG_ERROR,
244         Severity.ERROR,
245         ConfigurableAspect.ISSUE,
246         "Report issues of the given id as errors.",
247     ),
248     ERROR_WHEN_NEW(
249         ARG_ERROR_WHEN_NEW,
250         Severity.WARNING_ERROR_WHEN_NEW,
251         ConfigurableAspect.ISSUE,
252         """
253             Report issues of the given id as warnings in existing code and errors in new code. The
254             latter behavior relies on infrastructure that handles checking changes to the code
255             detecting the ${ERROR_WHEN_NEW_SUFFIX.trim()} text in the output and preventing the
256             change from being made.
257         """,
258     ),
259     WARNING(
260         ARG_WARNING,
261         Severity.WARNING,
262         ConfigurableAspect.ISSUE,
263         "Report issues of the given id as warnings.",
264     ),
265     HIDE(
266         ARG_HIDE,
267         Severity.HIDDEN,
268         ConfigurableAspect.ISSUE,
269         "Hide/skip issues of the given id.",
270     ),
271     ERROR_CATEGORY(
272         ARG_ERROR_CATEGORY,
273         Severity.ERROR,
274         ConfigurableAspect.CATEGORY,
275         "Report all issues in the given category as errors.",
276     ),
277     ERROR_WHEN_NEW_CATEGORY(
278         ARG_ERROR_WHEN_NEW_CATEGORY,
279         Severity.WARNING_ERROR_WHEN_NEW,
280         ConfigurableAspect.CATEGORY,
281         "Report all issues in the given category as errors-when-new.",
282     ),
283     WARNING_CATEGORY(
284         ARG_WARNING_CATEGORY,
285         Severity.WARNING,
286         ConfigurableAspect.CATEGORY,
287         "Report all issues in the given category as warnings.",
288     ),
289     HIDE_CATEGORY(
290         ARG_HIDE_CATEGORY,
291         Severity.HIDDEN,
292         ConfigurableAspect.CATEGORY,
293         "Hide/skip all issues in the given category.",
294     );
295 
296     /** Configure the aspect identified by [id] into the [configuration]. */
297     fun setAspectForId(reporter: Reporter, configuration: IssueConfiguration, id: String) {
298         aspect.setAspectSeverityForId(reporter, configuration, optionName, severity, id)
299     }
300 
301     companion object {
302         private val optionNameToLabel = ConfigLabel.values().associateBy { it.optionName }
303 
304         /**
305          * Get the label for the option name. This is only called with an option name that has been
306          * obtained from [ConfigLabel.optionName] so it is known that it must match.
307          */
308         fun fromOptionName(option: String): ConfigLabel = optionNameToLabel[option]!!
309     }
310 }
311