1/* 2 * Copyright 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 17package androidx.build.checkapi 18 19import androidx.build.doclava.ChecksConfig 20import org.gradle.api.DefaultTask 21import org.gradle.api.GradleException 22import org.gradle.api.tasks.Input 23import org.gradle.api.tasks.InputFile 24import org.gradle.api.tasks.InputFiles 25import org.gradle.api.tasks.Optional 26import org.gradle.api.tasks.OutputFile 27import org.gradle.api.tasks.TaskAction 28import java.io.ByteArrayInputStream 29import java.io.ByteArrayOutputStream 30import java.io.File 31import java.security.MessageDigest 32 33/** Character that resets console output color. */ 34private const val ANSI_RESET = "\u001B[0m" 35 36/** Character that sets console output color to red. */ 37private const val ANSI_RED = "\u001B[31m" 38 39/** Character that sets console output color to yellow. */ 40private const val ANSI_YELLOW = "\u001B[33m" 41 42private val ERROR_REGEX = Regex("^(.+):(.+): (\\w+) (\\d+): (.+)$") 43 44private fun ByteArray.encodeHex() = fold(StringBuilder(), { builder, byte -> 45 val hexString = Integer.toHexString(byte.toInt() and 0xFF) 46 if (hexString.length < 2) { 47 builder.append("0") 48 } 49 builder.append(hexString) 50}).toString() 51 52private fun getShortHash(src: String): String { 53 val str = MessageDigest.getInstance("SHA-1") 54 .digest(src.toByteArray()).encodeHex() 55 val len = str.length 56 return str.substring(len - 7, len) 57} 58 59/** 60 * Task used to verify changes between two API files. 61 * <p> 62 * This task may be configured to ignore, warn, or fail with a message for a specific set of 63 * Doclava-defined error codes. See {@link com.google.doclava.Errors} for a complete list of 64 * supported error codes. 65 * <p> 66 * Specific failures may be ignored by specifying a list of SHAs in {@link #whitelistErrors}. Each 67 * SHA is unique to a specific API change and is logged to the error output on failure. 68 */ 69open class CheckApiTask : DefaultTask() { 70 71 /** API file that represents the existing API surface. */ 72 @Optional 73 @InputFile 74 var oldApiFile: File? = null 75 76 /** API file that represents the existing API surface's removals. */ 77 @Optional 78 @InputFile 79 var oldRemovedApiFile: File? = null 80 81 /** API file that represents the candidate API surface. */ 82 @InputFile 83 lateinit var newApiFile: File 84 85 /** API file that represents the candidate API surface's removals. */ 86 @Optional 87 @InputFile 88 var newRemovedApiFile: File? = null 89 90 /** Optional file containing a newline-delimited list of error SHAs to ignore. */ 91 var whitelistErrorsFile: File? = null 92 93 @Optional 94 @InputFile 95 fun getWhiteListErrorsFileInput(): File? { 96 // Gradle requires non-null InputFiles to exist -- even with Optional -- so work around that 97 // by returning null for this field if the file doesn't exist. 98 if (whitelistErrorsFile?.exists() == true) { 99 return whitelistErrorsFile 100 } 101 return null 102 } 103 104 /** 105 * Optional set of error SHAs to ignore. 106 * <p> 107 * Each error SHA is unique to a specific API change. 108 */ 109 @Optional 110 @Input 111 var whitelistErrors = emptySet<String>() 112 113 var detectedWhitelistErrors = mutableSetOf<String>() 114 115 @InputFiles 116 var doclavaClasspath: Collection<File> = emptyList() 117 118 // A dummy output file meant only to tag when this check was last ran. 119 // Without any outputs, Gradle will run this task every time. 120 @Optional 121 private var mOutputFile: File? = null 122 123 @OutputFile 124 fun getOutputFile(): File { 125 return if (mOutputFile != null) { 126 mOutputFile!! 127 } else { 128 File(project.buildDir, "checkApi/$name-completed") 129 } 130 } 131 132 @Optional 133 fun setOutputFile(outputFile: File) { 134 mOutputFile = outputFile 135 } 136 137 @Input 138 lateinit var checksConfig: ChecksConfig 139 140 init { 141 group = "Verification" 142 description = "Invoke Doclava\'s ApiCheck tool to make sure current.txt is up to date." 143 } 144 145 private fun collectAndVerifyInputs(): Set<File> { 146 if (oldRemovedApiFile != null && newRemovedApiFile != null) { 147 return setOf(oldApiFile!!, newApiFile, oldRemovedApiFile!!, newRemovedApiFile!!) 148 } else { 149 return setOf(oldApiFile!!, newApiFile) 150 } 151 } 152 153 @TaskAction 154 fun exec() { 155 if (oldApiFile == null) { 156 // Nothing to do. 157 return 158 } 159 160 val apiFiles = collectAndVerifyInputs() 161 162 val errStream = ByteArrayOutputStream() 163 164 // If either of those gets tweaked, then this should be refactored to extend JavaExec. 165 project.javaexec { spec -> 166 spec.apply { 167 // Put Doclava on the classpath so we can get the ApiCheck class. 168 classpath(doclavaClasspath) 169 main = "com.google.doclava.apicheck.ApiCheck" 170 171 minHeapSize = "128m" 172 maxHeapSize = "1024m" 173 174 // add -error LEVEL for every error level we want to fail the build on. 175 checksConfig.errors.forEach { args("-error", it) } 176 checksConfig.warnings.forEach { args("-warning", it) } 177 checksConfig.hidden.forEach { args("-hide", it) } 178 179 spec.args(apiFiles.map { it.absolutePath }) 180 181 // Redirect error output so that we can whitelist specific errors. 182 errorOutput = errStream 183 // We will be handling failures ourselves with a custom message. 184 setIgnoreExitValue(true) 185 } 186 } 187 188 // Load the whitelist file, if present. 189 val whitelistFile = whitelistErrorsFile 190 if (whitelistFile?.exists() == true) { 191 whitelistErrors += whitelistFile.readLines() 192 } 193 194 // Parse the error output. 195 val unparsedErrors = mutableSetOf<String>() 196 val detectedErrors = mutableSetOf<List<String>>() 197 val parsedErrors = mutableSetOf<List<String>>() 198 ByteArrayInputStream(errStream.toByteArray()).bufferedReader().lines().forEach { 199 val match = ERROR_REGEX.matchEntire(it) 200 201 if (match == null) { 202 unparsedErrors.add(it) 203 } else if (match.groups[3]?.value == "error") { 204 val hash = getShortHash(match.groups[5]?.value!!) 205 val error = match.groupValues.subList(1, match.groupValues.size) + listOf(hash) 206 if (hash in whitelistErrors) { 207 detectedErrors.add(error) 208 detectedWhitelistErrors.add(error[5]) 209 } else { 210 parsedErrors.add(error) 211 } 212 } 213 } 214 215 unparsedErrors.forEach { error -> logger.error("$ANSI_RED$error$ANSI_RESET") } 216 parsedErrors.forEach { logger.error("$ANSI_RED${it[5]}$ANSI_RESET ${it[4]}") } 217 detectedErrors.forEach { logger.warn("$ANSI_YELLOW${it[5]}$ANSI_RESET ${it[4]}") } 218 219 if (unparsedErrors.isNotEmpty() || parsedErrors.isNotEmpty()) { 220 throw GradleException(checksConfig.onFailMessage ?: "") 221 } 222 223 // Just create a dummy file upon completion. Without any outputs, Gradle will run this task 224 // every time. 225 val outputFile = getOutputFile() 226 outputFile.parentFile.mkdirs() 227 outputFile.createNewFile() 228 } 229}