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