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.reporter
18 
19 import java.io.File
20 import java.io.PrintWriter
21 import kotlin.text.Charsets.UTF_8
22 
23 const val DEFAULT_BASELINE_NAME = "baseline.txt"
24 
25 private const val BASELINE_FILE_HEADER = "// Baseline format: 1.0\n"
26 
27 class Baseline
28 private constructor(
29     /** Description of this baseline. e.g. "api-lint", which is written into the file. */
30     private val description: String,
31     /**
32      * The optional input baseline file.
33      *
34      * If this exists and [updateFile] is either not set, or set to a different path then this file
35      * is read in and used to filter out known issues. If [updateFile] is set then the known issues
36      * read in from this file will be included in those written to [updateFile].
37      */
38     val file: File?,
39     /**
40      * The file into which an updated version of the baseline will be written that would suppress
41      * all the known issues reported during this process.
42      */
43     val updateFile: File?,
44     /**
45      * The comment that will be written after the first line which marks this out as a baseline
46      * file.
47      */
48     private val headerComment: String,
49     /** Configuration common to all [Baseline] instances. */
50     private val config: Config,
51 ) {
52     data class Config(
53 
54         /** Configuration for the issues that will be stored in the baseline file. */
55         val issueConfiguration: IssueConfiguration,
56 
57         /** True if this should delete empty baseline files, false otherwise. */
58         val deleteEmptyBaselines: Boolean,
59 
60         /**
61          * The source directory roots, used to convert location information to be relative to a
62          * source directory.
63          */
64         val sourcePath: List<File>,
65     )
66 
67     /**
68      * Whether, when updating the baseline, we allow the metalava run to pass even if the baseline
69      * does not contain all issues that would normally fail the run (by default ERROR level).
70      */
71     private val silentUpdate: Boolean = updateFile != null && updateFile.path == file?.path
72 
73     /** Map from issue id to element id to message */
74     private val map = HashMap<Issues.Issue, MutableMap<String, String>>()
75 
76     /** Count of the number of issues read in. */
77     private var readCount = 0
78 
79     init {
80         if (file?.isFile == true && !silentUpdate) {
81             // We've set a baseline from an existing file: read it
82             read()
83         }
84     }
85 
86     /** Returns true if the given issue is listed in the baseline, otherwise false */
87     fun mark(key: BaselineKey, message: String, issue: Issues.Issue): Boolean {
88         val elementId = key.elementId(pathTransformer = this::transformBaselinePath)
89         return mark(elementId, message, issue)
90     }
91 
92     private fun MutableMap<String, String>.findOldMessageByElementId(elementId: String): String? {
93         get(elementId)?.let {
94             return it
95         }
96 
97         return null
98     }
99 
100     private fun mark(elementId: String, message: String, issue: Issues.Issue): Boolean {
101         val idMap: MutableMap<String, String>? =
102             map[issue]
103                 ?: run {
104                     if (updateFile != null) {
105                         val new = HashMap<String, String>()
106                         map[issue] = new
107                         new
108                     } else {
109                         null
110                     }
111                 }
112 
113         idMap?.findOldMessageByElementId(elementId)?.let {
114             // Do not match the error messages; the ids are unique enough and allows us
115             // to tweak issue messages compatibly without recording all the deltas here.
116             return true
117         }
118 
119         if (updateFile != null) {
120             idMap?.set(elementId, message)
121 
122             // When creating baselines don't report issues
123             if (silentUpdate) {
124                 return true
125             }
126         }
127 
128         return false
129     }
130 
131     /**
132      * Transform the path (which is absolute) so that it is relative to one of the source roots, and
133      * make sure that it uses `/` consistently as the file separator so that the generated files are
134      * platform independent.
135      */
136     private fun transformBaselinePath(path: String): String {
137         for (sourcePath in config.sourcePath) {
138             if (path.startsWith(sourcePath.path)) {
139                 return path.substring(sourcePath.path.length).replace('\\', '/').removePrefix("/")
140             }
141         }
142 
143         return path.replace('\\', '/')
144     }
145 
146     /**
147      * Close the baseline file. If "update file" is set, update this file, and returns TRUE. If not,
148      * returns false.
149      */
150     fun close(): Boolean {
151         return write()
152     }
153 
154     private fun read() {
155         val file = this.file ?: return
156         val lines = file.readLines(UTF_8)
157         for (i in 0 until lines.size - 1) {
158             val line = lines[i]
159             if (
160                 line.startsWith("//") ||
161                     line.startsWith("#") ||
162                     line.isBlank() ||
163                     line.startsWith(" ")
164             ) {
165                 continue
166             }
167             val idEnd = line.indexOf(':')
168             val elementEnd = line.indexOf(':', idEnd + 1)
169             if (idEnd == -1 || elementEnd == -1) {
170                 println("Invalid metalava baseline format: $line")
171             }
172             val issueId = line.substring(0, idEnd).trim()
173             val elementId = line.substring(idEnd + 2, elementEnd).trim()
174 
175             val message = lines[i + 1].trim()
176 
177             val issue = Issues.findIssueById(issueId)
178             if (issue == null) {
179                 println("Invalid metalava baseline file: unknown issue id '$issueId'")
180             } else {
181                 val newIdMap =
182                     map[issue]
183                         ?: run {
184                             val new = HashMap<String, String>()
185                             map[issue] = new
186                             new
187                         }
188                 newIdMap[elementId] = message
189             }
190         }
191         readCount = map.values.sumOf { it.size }
192     }
193 
194     private fun write(): Boolean {
195         val updateFile = this.updateFile ?: return false
196         if (map.isNotEmpty() || !config.deleteEmptyBaselines) {
197             val sb = StringBuilder()
198             sb.append(BASELINE_FILE_HEADER)
199             sb.append(headerComment)
200 
201             map.keys
202                 .asSequence()
203                 .sortedBy { it.name }
204                 .forEach { issue ->
205                     val idMap = map[issue]
206                     idMap?.keys?.sorted()?.forEach { elementId ->
207                         val message = idMap[elementId]!!
208                         sb.append(issue.name).append(": ")
209                         sb.append(elementId)
210                         sb.append(":\n    ")
211                         sb.append(message).append('\n')
212                     }
213                     sb.append("\n\n")
214                 }
215 
216             if (sb.endsWith("\n\n")) {
217                 sb.setLength(sb.length - 2)
218             }
219 
220             updateFile.parentFile?.mkdirs()
221             updateFile.writeText(sb.toString(), UTF_8)
222         } else {
223             updateFile.delete()
224         }
225         // Only output a message saying that the baseline file has been written if the map has had
226         // extra issues added to it since it was read in.
227         val totalCount = map.values.sumOf { it.size }
228         return totalCount > readCount
229     }
230 
231     fun dumpStats(writer: PrintWriter) {
232         val counts = mutableMapOf<Issues.Issue, Int>()
233         map.keys.asSequence().forEach { issue ->
234             val idMap = map[issue]
235             val count = idMap?.count() ?: 0
236             counts[issue] = count
237         }
238 
239         writer.println("Baseline issue type counts for $description baseline:")
240         writer.println(
241             "" +
242                 "    Count Issue Id                       Severity\n" +
243                 "    ---------------------------------------------\n"
244         )
245         val list = counts.entries.toMutableList()
246         list.sortWith(compareBy({ -it.value }, { it.key.name }))
247         var total = 0
248         val issueConfiguration = config.issueConfiguration
249         for (entry in list) {
250             val count = entry.value
251             val issue = entry.key
252             writer.println(
253                 "    ${String.format("%5d", count)} ${String.format("%-30s", issue.name)} ${issueConfiguration.getSeverity(issue)}"
254             )
255             total += count
256         }
257         writer.println(
258             "" +
259                 "    ---------------------------------------------\n" +
260                 "    ${String.format("%5d", total)}"
261         )
262         writer.println()
263     }
264 
265     /**
266      * Builder for [Baseline]. [build] will return a non-null [Baseline] if either [file] or
267      * [updateFile] is set.
268      */
269     class Builder {
270         var description: String = ""
271 
272         var file: File? = null
273             set(value) {
274                 if (field != null) {
275                     throw IllegalStateException(
276                         "Only one baseline is allowed; found both $field and $value"
277                     )
278                 }
279                 field = value
280             }
281 
282         var updateFile: File? = null
283             set(value) {
284                 if (field != null) {
285                     throw IllegalStateException(
286                         "Only one update-baseline is allowed; found both $field and $value"
287                     )
288                 }
289                 field = value
290             }
291 
292         var headerComment: String = ""
293 
294         fun build(config: Config): Baseline? {
295             // If neither file nor updateFile is set, don't return an instance.
296             if (file == null && updateFile == null) {
297                 return null
298             }
299             if (description.isEmpty()) {
300                 throw IllegalStateException("Baseline description must be set")
301             }
302             return Baseline(
303                 description = description,
304                 file = file,
305                 updateFile = updateFile,
306                 headerComment = headerComment,
307                 config = config,
308             )
309         }
310     }
311 }
312