1/*
2 * Copyright (C) 2016 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 android.support.checkapi;
18
19import org.gradle.api.DefaultTask
20import org.gradle.api.Nullable
21import org.gradle.api.GradleException
22import org.gradle.api.InvalidUserDataException
23import org.gradle.api.tasks.Input
24import org.gradle.api.tasks.InputFile
25import org.gradle.api.tasks.InputFiles
26import org.gradle.api.tasks.ParallelizableTask
27import org.gradle.api.tasks.TaskAction
28import org.gradle.api.tasks.Optional
29import org.gradle.api.tasks.OutputFile
30import org.gradle.process.ExecResult
31
32import java.security.MessageDigest
33
34/**
35 * Task used to verify changes between two API files.
36 * <p>
37 * This task may be configured to ignore, warn, or fail with a message for a specific set of
38 * Doclava-defined error codes. See {@link com.google.doclava.Errors} for a complete list of
39 * supported error codes.
40 * <p>
41 * Specific failures may be ignored by specifying a list of SHAs in {@link #whitelistErrors}. Each
42 * SHA is unique to a specific API change and is logged to the error output on failure.
43 */
44@ParallelizableTask
45public class CheckApiTask extends DefaultTask {
46    /** Character that resets console output color. */
47    private static final String ANSI_RESET = "\u001B[0m";
48
49    /** Character that sets console output color to red. */
50    private static final String ANSI_RED = "\u001B[31m";
51
52    /** Character that sets console output color to yellow. */
53    private static final String ANSI_YELLOW = "\u001B[33m";
54
55    /** API file that represents the existing API surface. */
56    @InputFile
57    File oldApiFile
58
59    /** API file that represents the existing API surface's removals. */
60    @InputFile
61    File oldRemovedApiFile
62
63    /** API file that represents the candidate API surface. */
64    @InputFile
65    File newApiFile
66
67    /** API file that represents the candidate API surface's removals. */
68    @InputFile
69    File newRemovedApiFile
70
71    /** Optional file containing a newline-delimited list of error SHAs to ignore. */
72    @Nullable
73    File whitelistErrorsFile
74
75    @Optional
76    @Nullable
77    @InputFile
78    File getWhiteListErrorsFileInput() {
79        // Gradle requires non-null InputFiles to exist -- even with Optional -- so work around that
80        // by returning null for this field if the file doesn't exist.
81        if (whitelistErrorsFile && whitelistErrorsFile.exists()) {
82            return whitelistErrorsFile;
83        }
84        return null;
85    }
86
87    /**
88     * Optional list of packages to ignore.
89     * <p>
90     * Packages names will be matched exactly; sub-packages are not automatically recognized.
91     */
92    @Optional
93    @Nullable
94    @Input
95    Collection ignoredPackages = null
96
97    /**
98     * Optional list of classes to ignore.
99     * <p>
100     * Class names will be matched exactly by their fully-qualified names; inner classes are not
101     * automatically recognized.
102     */
103    @Optional
104    @Nullable
105    @Input
106    Collection ignoredClasses = null
107
108    /**
109     * Optional set of error SHAs to ignore.
110     * <p>
111     * Each error SHA is unique to a specific API change.
112     */
113    @Optional
114    @Input
115    Set whitelistErrors = []
116
117    @InputFiles
118    Collection<File> doclavaClasspath
119
120    // A dummy output file meant only to tag when this check was last ran.
121    // Without any outputs, Gradle will run this task every time.
122    @Optional
123    @Nullable
124    private File mOutputFile = null;
125
126    @OutputFile
127    public File getOutputFile() {
128        return mOutputFile ?: new File(project.buildDir, "checkApi/${name}-completed")
129    }
130
131    @Optional
132    public void setOutputFile(File outputFile) {
133        mOutputFile = outputFile
134    }
135
136    /**
137     * List of Doclava error codes to treat as errors.
138     * <p>
139     * See {@link com.google.doclava.Errors} for a complete list of error codes.
140     */
141    @Input
142    Collection checkApiErrors
143
144    /**
145     * List of Doclava error codes to treat as warnings.
146     * <p>
147     * See {@link com.google.doclava.Errors} for a complete list of error codes.
148     */
149    @Input
150    Collection checkApiWarnings
151
152    /**
153     * List of Doclava error codes to ignore.
154     * <p>
155     * See {@link com.google.doclava.Errors} for a complete list of error codes.
156     */
157    @Input
158    Collection checkApiHidden
159
160    /** Message to display on API check failure. */
161    @Input
162    String onFailMessage
163
164    public CheckApiTask() {
165        group = 'Verification'
166        description = 'Invoke Doclava\'s ApiCheck tool to make sure current.txt is up to date.'
167    }
168
169    private Set<File> collectAndVerifyInputs() {
170        Set<File> apiFiles = [getOldApiFile(), getNewApiFile(), getOldRemovedApiFile(),
171                              getNewRemovedApiFile()] as Set
172        if (apiFiles.size() != 4) {
173            throw new InvalidUserDataException("""Conflicting input files:
174    oldApiFile: ${getOldApiFile()}
175    newApiFile: ${getNewApiFile()}
176    oldRemovedApiFile: ${getOldRemovedApiFile()}
177    newRemovedApiFile: ${getNewRemovedApiFile()}
178All of these must be distinct files.""")
179        }
180        return apiFiles;
181    }
182
183    public void setCheckApiErrors(Collection errors) {
184        // Make it serializable.
185        checkApiErrors = errors as int[]
186    }
187
188    public void setCheckApiWarnings(Collection warnings) {
189        // Make it serializable.
190        checkApiWarnings = warnings as int[]
191    }
192
193    public void setCheckApiHidden(Collection hidden) {
194        // Make it serializable.
195        checkApiHidden = hidden as int[]
196    }
197
198    @TaskAction
199    public void exec() {
200        final def apiFiles = collectAndVerifyInputs()
201
202        OutputStream errStream = new ByteArrayOutputStream()
203
204        // If either of those gets tweaked, then this should be refactored to extend JavaExec.
205        project.javaexec {
206            // Put Doclava on the classpath so we can get the ApiCheck class.
207            classpath(getDoclavaClasspath())
208            main = 'com.google.doclava.apicheck.ApiCheck'
209
210            minHeapSize = '128m'
211            maxHeapSize = '1024m'
212
213            // [other options] old_api.txt new_api.txt old_removed_api.txt new_removed_api.txt
214
215            // add -error LEVEL for every error level we want to fail the build on.
216            getCheckApiErrors().each { args('-error', it) }
217            getCheckApiWarnings().each { args('-warning', it) }
218            getCheckApiHidden().each { args('-hide', it) }
219
220            Collection ignoredPackages = getIgnoredPackages()
221            if (ignoredPackages) {
222                ignoredPackages.each { args('-ignorePackage', it) }
223            }
224            Collection ignoredClasses = getIgnoredClasses()
225            if (ignoredClasses) {
226                ignoredClasses.each { args('-ignoreClass', it) }
227            }
228
229            args(apiFiles.collect( { it.absolutePath } ))
230
231            // Redirect error output so that we can whitelist specific errors.
232            errorOutput = errStream
233
234            // We will be handling failures ourselves with a custom message.
235            ignoreExitValue = true
236        }
237
238        // Load the whitelist file, if present.
239        if (whitelistErrorsFile && whitelistErrorsFile.exists()) {
240            whitelistErrors += whitelistErrorsFile.readLines()
241        }
242
243        // Parse the error output.
244        def unparsedErrors = []
245        def ignoredErrors = []
246        def parsedErrors = []
247        errStream.toString().split("\n").each {
248            if (it) {
249                def matcher = it =~ ~/^(.+):(.+): (\w+) (\d+): (.+)$/
250                if (!matcher) {
251                    unparsedErrors += [it]
252                } else if (matcher[0][3] == "error") {
253                    def hash = getShortHash(matcher[0][5]);
254                    def error = matcher[0][1..-1] + [hash]
255                    if (hash in whitelistErrors) {
256                        ignoredErrors += [error]
257                    } else {
258                        parsedErrors += [error]
259                    }
260                }
261            }
262        }
263
264        unparsedErrors.each { error -> logger.error "$ANSI_RED$error$ANSI_RESET" }
265        parsedErrors.each { logger.error "$ANSI_RED${it[5]}$ANSI_RESET ${it[4]}"}
266        ignoredErrors.each { logger.warn "$ANSI_YELLOW${it[5]}$ANSI_RESET ${it[4]}"}
267
268        if (unparsedErrors || parsedErrors) {
269            throw new GradleException(onFailMessage)
270        }
271
272        // Just create a dummy file upon completion. Without any outputs, Gradle will run this task
273        // every time.
274        File outputFile = getOutputFile()
275        outputFile.parentFile.mkdirs()
276        outputFile.createNewFile()
277    }
278
279    def getShortHash(src) {
280        return MessageDigest.getInstance("SHA-1")
281                .digest(src.toString().bytes)
282                .encodeHex()
283                .toString()[-7..-1]
284    }
285}