1 /*
2  * Copyright (C) 2024 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.reporter
18 
19 import com.android.tools.metalava.reporter.Severity.ERROR
20 import com.android.tools.metalava.reporter.Severity.HIDDEN
21 import com.android.tools.metalava.reporter.Severity.WARNING
22 import java.io.File
23 import java.io.OutputStreamWriter
24 import java.io.PrintWriter
25 import java.nio.file.Path
26 import java.util.function.Predicate
27 
28 class DefaultReporter(
29     private val environment: ReporterEnvironment,
30     private val issueConfiguration: IssueConfiguration,
31 
32     /** [Baseline] file associated with this [Reporter]. */
33     private val baseline: Baseline? = null,
34 
35     /**
36      * An error message associated with this [Reporter], which should be shown to the user when
37      * metalava finishes with errors.
38      */
39     private val errorMessage: String? = null,
40 
41     /** Filter to hide issues reported on specific types of [Reportable]. */
42     private val reportableFilter: Predicate<Reportable>? = null,
43 
44     /** Additional config properties. */
45     private val config: Config = Config(),
46 ) : Reporter {
47 
48     /** A list of [Report] objects containing all the reported issues. */
49     private val reports = mutableListOf<Report>()
50 
51     private var warningCount = 0
52 
53     /**
54      * Configuration properties for the reporter.
55      *
56      * This contains properties that are shared across all instances of [DefaultReporter], except
57      * for the bootstrapping reporter. That receives a default instance of this.
58      */
59     class Config(
60         /** If true, treat all warnings as errors */
61         val warningsAsErrors: Boolean = false,
62 
63         /** Formats the report suitable for use in a file. */
64         val fileReportFormatter: ReportFormatter = DefaultReportFormatter.DEFAULT,
65 
66         /** Formats the report for output, e.g. to a terminal. */
67         val outputReportFormatter: ReportFormatter = fileReportFormatter,
68 
69         /**
70          * Optional writer to which, if present, all errors, even if they were suppressed in
71          * baseline or via annotation, will be written.
72          */
73         val reportEvenIfSuppressedWriter: PrintWriter? = null,
74     )
75 
76     /** The number of errors. */
77     var errorCount: Int = 0
78         private set
79 
80     /** Returns whether any errors have been detected. */
hasErrorsnull81     fun hasErrors(): Boolean = errorCount > 0
82 
83     override fun report(
84         id: Issues.Issue,
85         reportable: Reportable?,
86         message: String,
87         location: FileLocation,
88         maximumSeverity: Severity,
89     ): Boolean {
90         val severity = issueConfiguration.getSeverity(id)
91         val upgradedSeverity =
92             if (severity == WARNING && config.warningsAsErrors) {
93                 ERROR
94             } else {
95                 severity
96             }
97 
98         // Limit the Severity to the maximum allowed.
99         val effectiveSeverity = minOf(upgradedSeverity, maximumSeverity)
100         if (effectiveSeverity == HIDDEN) {
101             return false
102         }
103 
104         // When selecting a location to use for reporting the issue the location is used in
105         // preference to the item because the location is more specific. e.g. if the item is a
106         // method then the location may be a line within the body of the method.
107         val reportLocation =
108             when {
109                 location.path != null -> location
110                 else -> reportable?.fileLocation
111             }
112 
113         val report =
114             Report(
115                 severity = effectiveSeverity,
116                 // Relativize the path before storing in the Report.
117                 relativePath = reportLocation?.path?.relativizeLocationPath(),
118                 line = reportLocation?.line ?: 0,
119                 message = message,
120                 issue = id,
121             )
122 
123         // Optionally write to the --report-even-if-suppressed file.
124         reportEvenIfSuppressed(report)
125 
126         if (isSuppressed(id, reportable, message)) {
127             return false
128         }
129 
130         // Apply the reportable filter if one is provided.
131         if (reportable != null && reportableFilter?.test(reportable) == false) {
132             return false
133         }
134 
135         if (baseline != null) {
136             // When selecting a key to use for in checking the baseline the reportable key is used
137             // in preference to the location because the reportable key is more stable. e.g. the
138             // location key may be for a specific line within a method which would change over time
139             // while a key based off a method's would stay the same.
140             val baselineKey =
141                 when {
142                     // When available use the baseline key from the reportable.
143                     reportable != null -> reportable.baselineKey
144                     // Otherwise, use the baseline key from the file location.
145                     else -> location.baselineKey
146                 }
147 
148             if (baselineKey != null && baseline.mark(baselineKey, message, id)) return false
149         }
150 
151         return doReport(report)
152     }
153 
isSuppressednull154     override fun isSuppressed(
155         id: Issues.Issue,
156         reportable: Reportable?,
157         message: String?
158     ): Boolean {
159         val severity = issueConfiguration.getSeverity(id)
160         if (severity == HIDDEN) {
161             return true
162         }
163 
164         reportable ?: return false
165 
166         // Suppress the issue if requested for the item.
167         return reportable.suppressedIssues().any { suppressMatches(it, id.name, message) }
168     }
169 
suppressMatchesnull170     private fun suppressMatches(value: String, id: String?, message: String?): Boolean {
171         id ?: return false
172 
173         if (value == id) {
174             return true
175         }
176 
177         if (
178             message != null &&
179                 value.startsWith(id) &&
180                 value.endsWith(message) &&
181                 (value == "$id:$message" || value == "$id: $message")
182         ) {
183             return true
184         }
185 
186         return false
187     }
188 
189     /**
190      * Relativize this against the [ReporterEnvironment.rootFolder] if specified.
191      *
192      * Tests will set [ReporterEnvironment.rootFolder] to the temporary directory so that this can
193      * remove that from any paths that are reported to avoid the test having to be aware of the
194      * temporary directory.
195      */
Pathnull196     private fun Path.relativizeLocationPath(): String {
197         // b/255575766: Note that `relativize` requires two paths to compare to have same types:
198         // either both of them are absolute paths or both of them are not absolute paths.
199         val path = environment.rootFolder.toPath().relativize(this) ?: this
200         return path.toString()
201     }
202 
203     /** Alias to allow method reference to `dispatch` in [report] */
doReportnull204     private fun doReport(report: Report): Boolean {
205         val severity = report.severity
206         when (severity) {
207             ERROR -> errorCount++
208             WARNING -> warningCount++
209             else -> {}
210         }
211 
212         reports.add(report)
213         return true
214     }
215 
reportEvenIfSuppressednull216     private fun reportEvenIfSuppressed(report: Report): Boolean {
217         config.reportEvenIfSuppressedWriter?.println(config.fileReportFormatter.format(report))
218         return true
219     }
220 
221     /** Print all the recorded errors to the given writer. Returns the number of errors printed. */
printErrorsnull222     fun printErrors(writer: PrintWriter, maxErrors: Int): Int {
223         val errors = reports.filter { it.severity == ERROR }.take(maxErrors)
224         for (error in errors) {
225             val formattedMessage = config.outputReportFormatter.format(error)
226             writer.println(formattedMessage)
227         }
228         return errors.size
229     }
230 
231     /** Write all reports. */
writeSavedReportsnull232     fun writeSavedReports() {
233         // Sort the reports in place. This will ensure that the errors output in [printErrors] are
234         // also sorted in the same order as that is called after this.
235         reports.sortWith(reportComparator)
236 
237         // Print out all the save reports.
238         for (report in reports) {
239             val formattedMessage = config.outputReportFormatter.format(report)
240             environment.printReport(formattedMessage, report.severity)
241         }
242     }
243 
244     /** Write the error message set to this [Reporter], if any errors have been detected. */
writeErrorMessagenull245     fun writeErrorMessage(writer: PrintWriter) {
246         if (hasErrors()) {
247             errorMessage?.let { writer.write(it) }
248         }
249     }
250 
251     companion object {
252         private val reportComparator =
253             compareBy<Report>(
<lambda>null254                 { it.relativePath },
<lambda>null255                 { it.line },
<lambda>null256                 { it.severity },
<lambda>null257                 { it.issue?.name },
<lambda>null258                 { it.message },
259             )
260     }
261 }
262 
263 /**
264  * Provides access to information about the environment within which the [Reporter] will be being
265  * used.
266  */
267 interface ReporterEnvironment {
268 
269     /** Root folder, against which location paths will be relativized to simplify the output. */
270     val rootFolder: File
271 
272     /** Print the report. */
printReportnull273     fun printReport(message: String, severity: Severity)
274 }
275 
276 class DefaultReporterEnvironment(
277     val stdout: PrintWriter = PrintWriter(OutputStreamWriter(System.out)),
278     val stderr: PrintWriter = PrintWriter(OutputStreamWriter(System.err)),
279 ) : ReporterEnvironment {
280 
281     override val rootFolder = File("").absoluteFile
282 
283     override fun printReport(message: String, severity: Severity) {
284         val output = if (severity == ERROR) stderr else stdout
285         output.println(message.trim())
286         output.flush()
287     }
288 }
289