1 /*
2  * Copyright (C) 2017 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
18 
19 import com.android.tools.metalava.cli.common.Terminal
20 import com.android.tools.metalava.cli.common.TerminalColor
21 import com.android.tools.metalava.cli.common.plainTerminal
22 import com.android.tools.metalava.model.Item
23 import com.android.tools.metalava.model.PackageItem
24 import com.android.tools.metalava.reporter.Baseline
25 import com.android.tools.metalava.reporter.FileLocation
26 import com.android.tools.metalava.reporter.IssueConfiguration
27 import com.android.tools.metalava.reporter.Issues
28 import com.android.tools.metalava.reporter.Reportable
29 import com.android.tools.metalava.reporter.Reporter
30 import com.android.tools.metalava.reporter.Severity
31 import com.android.tools.metalava.reporter.Severity.ERROR
32 import com.android.tools.metalava.reporter.Severity.HIDDEN
33 import com.android.tools.metalava.reporter.Severity.INFO
34 import com.android.tools.metalava.reporter.Severity.INHERIT
35 import com.android.tools.metalava.reporter.Severity.WARNING
36 import com.android.tools.metalava.reporter.Severity.WARNING_ERROR_WHEN_NEW
37 import java.io.File
38 import java.io.OutputStreamWriter
39 import java.io.PrintWriter
40 import java.nio.file.Path
41 
42 internal class DefaultReporter(
43     private val environment: ReporterEnvironment,
44     private val issueConfiguration: IssueConfiguration,
45 
46     /** [Baseline] file associated with this [Reporter]. */
47     private val baseline: Baseline? = null,
48 
49     /**
50      * An error message associated with this [Reporter], which should be shown to the user when
51      * metalava finishes with errors.
52      */
53     private val errorMessage: String? = null,
54 
55     /** Filter to hide issues reported in packages which are not part of the API. */
56     private val packageFilter: PackageFilter? = null,
57 
58     /** Additional config properties. */
59     private val config: Config = Config(),
60 ) : Reporter {
61     private var errors = mutableListOf<String>()
62     private var warningCount = 0
63 
64     /**
65      * Configuration properties for the reporter.
66      *
67      * This contains properties that are shared across all instances of [DefaultReporter], except
68      * for the bootstrapping reporter. That receives a default instance of this.
69      */
70     class Config(
71         /** If true, treat all warnings as errors */
72         val warningsAsErrors: Boolean = false,
73 
74         /** Whether output should be colorized */
75         val terminal: Terminal = plainTerminal,
76 
77         /**
78          * Optional writer to which, if present, all errors, even if they were suppressed in
79          * baseline or via annotation, will be written.
80          */
81         val reportEvenIfSuppressedWriter: PrintWriter? = null,
82     )
83 
84     /** The number of errors. */
85     val errorCount
86         get() = errors.size
87 
88     /** Returns whether any errors have been detected. */
hasErrorsnull89     fun hasErrors(): Boolean = errors.size > 0
90 
91     override fun report(
92         id: Issues.Issue,
93         reportable: Reportable?,
94         message: String,
95         location: FileLocation,
96         maximumSeverity: Severity,
97     ): Boolean {
98         val severity = issueConfiguration.getSeverity(id)
99         val upgradedSeverity =
100             if (severity == WARNING && config.warningsAsErrors) {
101                 ERROR
102             } else {
103                 severity
104             }
105 
106         // Limit the Severity to the maximum allowed.
107         val effectiveSeverity = minOf(upgradedSeverity, maximumSeverity)
108         if (effectiveSeverity == HIDDEN) {
109             return false
110         }
111 
112         fun dispatch(
113             which:
114                 (
115                     severity: Severity, location: String?, message: String, id: Issues.Issue
116                 ) -> Boolean
117         ): Boolean {
118             // When selecting a location to use for reporting the issue the location is used in
119             // preference to the item because the location is more specific. e.g. if the item is a
120             // method then the location may be a line within the body of the method.
121             val reportLocation =
122                 when {
123                     location.path != null -> location
124                     else -> reportable?.fileLocation
125                 }
126 
127             return which(effectiveSeverity, reportLocation?.forReport(), message, id)
128         }
129 
130         // Optionally write to the --report-even-if-suppressed file.
131         dispatch(this::reportEvenIfSuppressed)
132 
133         if (isSuppressed(id, reportable, message)) {
134             return false
135         }
136 
137         // If we are only emitting some packages (--stub-packages), don't report
138         // issues from other packages
139         val item = reportable as? Item
140         if (item != null) {
141             if (packageFilter != null) {
142                 val pkg = (item as? PackageItem) ?: item.containingPackage()
143                 if (pkg != null && !packageFilter.matches(pkg)) {
144                     return false
145                 }
146             }
147         }
148 
149         if (baseline != null) {
150             // When selecting a location to use for in checking the baseline the item is used in
151             // preference to the location because the item is more stable. e.g. the location may be
152             // for a specific line within a method which would change over time while the method
153             // signature would stay the same.
154             val baselineKey =
155                 when {
156                     // When available use the baseline key from the reportable.
157                     reportable != null -> reportable.baselineKey
158                     // Otherwise, use the baseline key from the file location.
159                     else -> location.baselineKey
160                 }
161 
162             if (baselineKey != null && baseline.mark(baselineKey, message, id)) return false
163         }
164 
165         return dispatch(this::doReport)
166     }
167 
isSuppressednull168     override fun isSuppressed(
169         id: Issues.Issue,
170         reportable: Reportable?,
171         message: String?
172     ): Boolean {
173         val severity = issueConfiguration.getSeverity(id)
174         if (severity == HIDDEN) {
175             return true
176         }
177 
178         reportable ?: return false
179 
180         // Suppress the issue if requested for the item.
181         return reportable.suppressedIssues().any { suppressMatches(it, id.name, message) }
182     }
183 
suppressMatchesnull184     private fun suppressMatches(value: String, id: String?, message: String?): Boolean {
185         id ?: return false
186 
187         if (value == id) {
188             return true
189         }
190 
191         if (
192             message != null &&
193                 value.startsWith(id) &&
194                 value.endsWith(message) &&
195                 (value == "$id:$message" || value == "$id: $message")
196         ) {
197             return true
198         }
199 
200         return false
201     }
202 
203     /**
204      * Relativize the [absolutePath] against the [ReporterEnvironment.rootFolder] if specified.
205      *
206      * Tests will set [rootFolder] to the temporary directory so that this can remove that from any
207      * paths that are reported to avoid the test having to be aware of the temporary directory.
208      */
relativizeLocationPathnull209     private fun relativizeLocationPath(absolutePath: Path): String {
210         // b/255575766: Note that [relativize] requires two paths to compare to have same types:
211         // either both of them are absolute paths or both of them are not absolute paths.
212         val path = environment.rootFolder.toPath().relativize(absolutePath) ?: absolutePath
213         return path.toString()
214     }
215 
216     /**
217      * Convert the [FileLocation] to an optional string representation suitable for use in a report.
218      *
219      * See [relativizeLocationPath].
220      */
forReportnull221     private fun FileLocation.forReport(): String? {
222         val pathString = path?.let { relativizeLocationPath(it) } ?: return null
223         return if (line > 0) "$pathString:$line" else pathString
224     }
225 
226     /** Alias to allow method reference to `dispatch` in [report] */
doReportnull227     private fun doReport(
228         severity: Severity,
229         location: String?,
230         message: String,
231         id: Issues.Issue?,
232     ): Boolean {
233         val terminal: Terminal = config.terminal
234         val formattedMessage = format(severity, location, message, id, terminal)
235         if (severity == ERROR) {
236             errors.add(formattedMessage)
237         } else if (severity == WARNING) {
238             warningCount++
239         }
240 
241         environment.printReport(formattedMessage, severity)
242         return true
243     }
244 
formatnull245     private fun format(
246         severity: Severity,
247         location: String?,
248         message: String,
249         id: Issues.Issue?,
250         terminal: Terminal,
251     ): String {
252         val sb = StringBuilder(100)
253 
254         sb.append(terminal.attributes(bold = true))
255         location?.let { sb.append(it).append(": ") }
256         when (severity) {
257             INFO -> sb.append(terminal.attributes(foreground = TerminalColor.CYAN)).append("info: ")
258             WARNING,
259             WARNING_ERROR_WHEN_NEW ->
260                 sb.append(terminal.attributes(foreground = TerminalColor.YELLOW))
261                     .append("warning: ")
262             ERROR ->
263                 sb.append(terminal.attributes(foreground = TerminalColor.RED)).append("error: ")
264             INHERIT,
265             HIDDEN -> {}
266         }
267         sb.append(terminal.reset())
268         sb.append(message)
269         sb.append(severity.messageSuffix)
270         id?.let { sb.append(" [").append(it.name).append("]") }
271         return sb.toString()
272     }
273 
reportEvenIfSuppressednull274     private fun reportEvenIfSuppressed(
275         severity: Severity,
276         location: String?,
277         message: String,
278         id: Issues.Issue
279     ): Boolean {
280         config.reportEvenIfSuppressedWriter?.println(
281             format(severity, location, message, id, terminal = plainTerminal)
282         )
283         return true
284     }
285 
286     /** Print all the recorded errors to the given writer. Returns the number of errors printer. */
printErrorsnull287     fun printErrors(writer: PrintWriter, maxErrors: Int): Int {
288         var i = 0
289         errors.forEach loop@{
290             if (i >= maxErrors) {
291                 return@loop
292             }
293             i++
294             writer.println(it)
295         }
296         return i
297     }
298 
299     /** Write the error message set to this [Reporter], if any errors have been detected. */
writeErrorMessagenull300     fun writeErrorMessage(writer: PrintWriter) {
301         if (hasErrors()) {
302             errorMessage?.let { writer.write(it) }
303         }
304     }
305 
getBaselineDescriptionnull306     fun getBaselineDescription(): String {
307         val file = baseline?.file
308         return if (file != null) {
309             "baseline ${file.path}"
310         } else {
311             "no baseline"
312         }
313     }
314 }
315 
316 /**
317  * Provides access to information about the environment within which the [Reporter] will be being
318  * used.
319  */
320 interface ReporterEnvironment {
321 
322     /** Root folder, against which location paths will be relativized to simplify the output. */
323     val rootFolder: File
324 
325     /** Print the report. */
printReportnull326     fun printReport(message: String, severity: Severity)
327 }
328 
329 class DefaultReporterEnvironment(
330     val stdout: PrintWriter = PrintWriter(OutputStreamWriter(System.out)),
331     val stderr: PrintWriter = PrintWriter(OutputStreamWriter(System.err)),
332 ) : ReporterEnvironment {
333 
334     override val rootFolder = File("").absoluteFile
335 
336     override fun printReport(message: String, severity: Severity) {
337         val output = if (severity == ERROR) stderr else stdout
338         output.println(message.trim())
339         output.flush()
340     }
341 }
342