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}