All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.android.tools.lint.client.api.LintBaseline Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.tools.lint.client.api;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Position;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.TextFormat;
import com.android.utils.XmlUtils;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.kxml2.io.KXmlParser;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;

/**
 * A lint baseline is a collection of warnings for a project that have been
 * obtained from a previous run of lint. These warnings are them exempt from
 * reporting. This lets you set a "baseline" with a known set of issues that you
 * haven't attempted to fix yet, but then be alerted whenever new issues crop
 * up.
 */
public class LintBaseline {
    /**
     * Root tag in baseline files (which can be the XML output report files from lint, or a
     * subset of these
     */
    @SuppressWarnings("unused") // used from IDE
    public static final String TAG_ISSUES = "issues";

    private static final String TAG_ISSUE = "issue";
    private static final String TAG_LOCATION = "location";

    private static final String ATTR_ID = "id";
    private static final String ATTR_MESSAGE = "message";
    private static final String ATTR_FILE = "file";
    private static final String ATTR_LINE = "line";
    private static final String ATTR_COLUMN = "column";

    /** Client to log to */
    private final LintClient client;

    /** Count of number of errors that were filtered out */
    private int foundErrorCount;

    /** Count of number of warnings that were filtered out */
    private int foundWarningCount;

    /** Raw number of issues found in the baseline when opened */
    private int baselineIssueCount;

    /** Map from message to {@link Entry} */
    private final Multimap messageToEntry = ArrayListMultimap.create(100, 20);

    /**
     * Whether we should write the baseline file when the baseline is closed, if the
     * baseline file doesn't already exist. We don't always do this because for example
     * when lint is run from Gradle, and it's analyzing multiple variants, it does its own
     * merging (across variants) of the results first and then writes that, via the
     * XML reporter.
     */
    private boolean writeOnClose;

    /**
     * Whether the baseline, when configured to write results into the file, will
     * include all found issues, or only issues that are already known. The difference
     * here is whether we're initially creating the baseline (or resetting it), or
     * whether we're trying to only remove fixed issues.
     */
    private boolean removeFixed;

    /**
     * The file to read the baselines from, and if {@link #writeOnClose} is set, to write
     * to when the baseline is {@link #close()}'ed.
     */
    private final File baselineFile;

    /**
     * If non-null, a list of issues to write back out to the baseline file when the
     * baseline is closed.
     */
    private List entriesToWrite;

    public LintBaseline(@NonNull LintClient client, @NonNull File baselineFile) {
        this.client = client;
        this.baselineFile = baselineFile;
        readBaselineFile();
    }

    /**
     * Checks if we should report baseline activity (filtered out issues, found fixed issues etc
     * and if so reports them
     */
    void reportBaselineIssues(@NonNull LintDriver driver, @NonNull Project project) {
        if (foundErrorCount > 0 || foundWarningCount > 0) {
            LintClient client = driver.getClient();
            File baselineFile = getFile();
            // Keep in sync with isFilteredMessage() below
            String message = String.format("%1$s were filtered out because "
                            + "they were listed in the baseline file, %2$s",
                    LintUtils.describeCounts(foundErrorCount, foundWarningCount, false),
                    getDisplayPath(project, baselineFile));
            client.report(new Context(driver, project, project, baselineFile),
                    IssueRegistry.BASELINE,
                    client.getConfiguration(project, driver).getSeverity(IssueRegistry.BASELINE),
                    Location.create(baselineFile), message, TextFormat.RAW);
        }

        int fixedCount = getFixedCount();
        if (fixedCount > 0 && !(writeOnClose && removeFixed)) {
            LintClient client = driver.getClient();
            File baselineFile = getFile();
            // Keep in sync with isFixedMessage() below
            String message = String.format("%1$d errors/warnings were listed in the "
                    + "baseline file (%2$s) but not found in the project; perhaps they have "
                    + "been fixed?", fixedCount, getDisplayPath(project, baselineFile));
            client.report(new Context(driver, project, project, baselineFile),
                    IssueRegistry.BASELINE,
                    client.getConfiguration(project, driver).getSeverity(IssueRegistry.BASELINE),
                    Location.create(baselineFile), message, TextFormat.RAW);
        }
    }

    /**
     * Given an error message produced by this lint detector for the given issue type,
     * determines whether this corresponds to the warning (produced by
     * {link {@link #reportBaselineIssues(LintDriver, Project)} above) that one or
     * more issues have been filtered out.
     * 

* Intended for IDE quickfix implementations. */ @SuppressWarnings("unused") // Used from the IDE public static boolean isFilteredMessage(@NonNull String errorMessage, @NonNull TextFormat format) { errorMessage = format.toText(errorMessage); return errorMessage.contains("were filtered out"); } /** * Given an error message produced by this lint detector for the given issue type, * determines whether this corresponds to the warning (produced by * {link {@link #reportBaselineIssues(LintDriver, Project)} above) that one or * more issues have been fixed (present in baseline but not in project.) *

* Intended for IDE quickfix implementations. */ @SuppressWarnings("unused") // Used from the IDE public static boolean isFixedMessage(@NonNull String errorMessage, @NonNull TextFormat format) { errorMessage = format.toText(errorMessage); return errorMessage.contains("perhaps they have been fixed"); } /** * Checks whether the given warning (of the given issue type, message and location) * is present in this baseline, and if so marks it as used such that a second call will * not find it. *

* When issue analysis is done you can call {@link #getFoundErrorCount()} and * {@link #getFoundWarningCount()} to get a count of the warnings or errors that were * matched during the run, and {@link #getFixedCount()} to get a count of the issues * that were present in the baseline that were not matched (e.g. have been fixed.) * * @param issue the issue type * @param location the location of the error * @param message the exact error message (in {@link TextFormat#RAW} format) * @param severity the severity of the issue, used to count baseline match as error or warning * @param project the relevant project, if any * @return true if this error was found in the baseline and marked as used, and false if this * issue is not part of the baseline */ public boolean findAndMark(@NonNull Issue issue, @NonNull Location location, @NonNull String message, @Nullable Severity severity, @Nullable Project project) { boolean found = findAndMark(issue, location, message, severity); if (writeOnClose && (!removeFixed || found)) { //noinspection VariableNotUsedInsideIf if (entriesToWrite != null) { entriesToWrite.add(new ReportedEntry(issue, project, location, message)); } } return found; } private boolean findAndMark(@NonNull Issue issue, @NonNull Location location, @NonNull String message, @Nullable Severity severity) { Collection entries = messageToEntry.get(message); if (entries == null || entries.isEmpty()) { return false; } File file = location.getFile(); String path = file.getPath(); String issueId = issue.getId(); for (Entry entry : entries) { if (entry.issueId.equals(issueId)) { if (isSamePathSuffix(path, entry.path)) { // Remove all linked entries. We don't loop through all the locations; // they're allowed to vary over time, we just assume that all entries // for the same warning should be cleared. while (entry.previous != null) { entry = entry.previous; } while (entry != null) { messageToEntry.remove(entry.message, entry); entry = entry.next; } if (severity == null) { severity = issue.getDefaultSeverity(); } if (severity.isError()) { foundErrorCount++; } else { foundWarningCount++; } return true; } } } return false; } /** Returns the number of errors that have been matched from the baseline */ public int getFoundErrorCount() { return foundErrorCount; } /** Returns the number of warnings that have been matched from the baseline */ public int getFoundWarningCount() { return foundWarningCount; } /** * Returns the number of issues that appear to have been fixed (e.g. are present * in the baseline but have not been matched */ public int getFixedCount() { return baselineIssueCount - foundErrorCount - foundWarningCount; } /** Returns the total number of issues contained in this baseline */ public int getTotalCount() { return baselineIssueCount; } /** Like path.endsWith(suffix), but considers \\ and / identical */ static boolean isSamePathSuffix(@NonNull String path, @NonNull String suffix) { int i = path.length() - 1; int j = suffix.length() - 1; if (j > i) { return false; } for (; j > 0; i--, j--) { char c1 = path.charAt(i); char c2 = suffix.charAt(j); if (c1 != c2) { if (c1 == '\\') { c1 = '/'; } if (c2 == '\\') { c2 = '/'; } if (c1 != c2) { return false; } } } return true; } /** Read in the XML report */ private void readBaselineFile() { if (!baselineFile.exists()) { return; } try (Reader reader = new BufferedReader(new FileReader(baselineFile))) { KXmlParser parser = new KXmlParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); parser.setInput(reader); String issue = null; String message = null; String path = null; String line = null; Entry currentEntry = null; while (parser.next() != XmlPullParser.END_DOCUMENT) { int eventType = parser.getEventType(); if (eventType == XmlPullParser.END_TAG) { String tag = parser.getName(); if (tag.equals(TAG_LOCATION)) { if (issue != null && message != null && path != null) { Entry entry = new Entry(issue, message, path, line); if (currentEntry != null) { currentEntry.next = entry; } entry.previous = currentEntry; currentEntry = entry; messageToEntry.put(entry.message, entry); } } else if (tag.equals(TAG_ISSUE)) { baselineIssueCount++; issue = null; message = null; path = null; line = null; currentEntry = null; } } else if (eventType != XmlPullParser.START_TAG) { continue; } for (int i = 0, n = parser.getAttributeCount(); i < n; i++) { String name = parser.getAttributeName(i); String value = parser.getAttributeValue(i); switch (name) { case ATTR_ID: issue = value; break; case ATTR_MESSAGE: message = value; break; case ATTR_FILE: path = value; break; case ATTR_LINE: line = value; break; } } } } catch (IOException | XmlPullParserException e) { if (client != null) { client.log(e, null); } else { e.printStackTrace(); } } } /** * Returns the file which records the data in this baseline * @return the baseline file */ @NonNull public File getFile() { return baselineFile; } /** Returns whether this baseline is writing its result upon close */ public boolean isWriteOnClose() { return writeOnClose; } /** Sets whether the baseline should write its matched entries on {@link #close()} */ public void setWriteOnClose(boolean writeOnClose) { if (writeOnClose) { int count = baselineIssueCount > 0 ? baselineIssueCount + 10 : 30; entriesToWrite = Lists.newArrayListWithCapacity(count); } this.writeOnClose = writeOnClose; } /** * Whether the baseline when writing the file will skip fixed issues, or include all. * @return true if skipping fixed issues */ public boolean isRemoveFixed() { return removeFixed; } /** * Whether the baseline when writing the file should skip fixed issues, or include all. * @param skipFixed true to skip fixed issues */ public void setRemoveFixed(boolean skipFixed) { this.removeFixed = skipFixed; } /** Finishes writing the baseline */ public void close() { if (writeOnClose) { File parentFile = baselineFile.getParentFile(); if (parentFile != null && !parentFile.exists()) { boolean mkdirs = parentFile.mkdirs(); if (!mkdirs) { client.log(null, "Couldn't create %1$s", parentFile); return; } } try (Writer writer = new BufferedWriter(new FileWriter(baselineFile))) { writer.write("\n"); // Format 4: added urls= attribute with all more info links, comma separated writer.write('<'); writer.write(TAG_ISSUES); writer.write(" format=\"4\""); String revision = client.getClientRevision(); if (revision != null) { writer.write(String.format(" by=\"lint %1$s\"", revision)); } writer.write(">\n"); baselineIssueCount = 0; if (entriesToWrite != null) { Collections.sort(entriesToWrite); for (ReportedEntry entry : entriesToWrite) { entry.write(writer, client); baselineIssueCount++; } } writer.write("\n\n"); writer.close(); } catch (IOException ioe) { client.log(ioe, null); } } } private static String getDisplayPath(@Nullable Project project, @NonNull File file) { String path = file.getPath(); if (project != null && path.startsWith(project.getReferenceDir().getPath())) { int chop = project.getReferenceDir().getPath().length(); if (path.length() > chop && path.charAt(chop) == File.separatorChar) { chop++; } path = path.substring(chop); if (path.isEmpty()) { path = file.getName(); } } return path; } private static void writeAttribute(Writer writer, int indent, String name, String value) throws IOException { writer.write('\n'); indent(writer, indent); writer.write(name); writer.write('='); writer.write('"'); writer.write(XmlUtils.toXmlAttributeValue(value)); writer.write('"'); } private static void indent(Writer writer, int indent) throws IOException { for (int level = 0; level < indent; level++) { writer.write(" "); } } /** * Entries that have been reported during this lint run. We only create these * when we need to write a baseline file (since we need to sort them before * writing out the result file, to ensure stable files. */ private static class ReportedEntry implements Comparable { public final Issue issue; public final String message; public final Location location; public final Project project; public ReportedEntry(@NonNull Issue issue, @Nullable Project project, @NonNull Location location, @NonNull String message) { this.issue = issue; this.location = location; this.project = project; this.message = message; } @Override public int compareTo(@NonNull ReportedEntry other) { // Sort by category, then by priority, then by id, // then by file, then by line int categoryDelta = issue.getCategory().compareTo(other.issue.getCategory()); if (categoryDelta != 0) { return categoryDelta; } // DECREASING priority order int priorityDelta = other.issue.getPriority() - issue.getPriority(); if (priorityDelta != 0) { return priorityDelta; } String id1 = issue.getId(); String id2 = other.issue.getId(); int idDelta = id1.compareTo(id2); if (idDelta != 0) { return idDelta; } File file = location.getFile(); File otherFile = other.location.getFile(); int fileDelta = file.getName().compareTo( otherFile.getName()); if (fileDelta != 0) { return fileDelta; } Position start = location.getStart(); Position otherStart = other.location.getStart(); int line = start != null ? start.getLine() : -1; int otherLine = otherStart != null ? otherStart.getLine() : -1; if (line != otherLine) { return line - otherLine; } int delta = message.compareTo(other.message); if (delta != 0) { return delta; } delta = file.compareTo(otherFile); if (delta != 0) { return delta; } Location secondary1 = location.getSecondary(); File secondaryFile1 = secondary1 != null ? secondary1.getFile() : null; Location secondary2 = other.location.getSecondary(); File secondaryFile2 = secondary2 != null ? secondary2.getFile() : null; if (secondaryFile1 != null) { if (secondaryFile2 != null) { return secondaryFile1.compareTo(secondaryFile2); } else { return -1; } } else //noinspection VariableNotUsedInsideIf if (secondaryFile2 != null) { return 1; } // This handles the case where you have a huge XML document without hewlines, // such that all the errors end up on the same line. if (start != null && otherStart != null) { delta = start.getColumn() - otherStart.getColumn(); if (delta != 0) { return delta; } } return 0; } /** * Given the report of an issue, add it to the baseline being built in the XML writer */ void write( @NonNull Writer writer, @NonNull LintClient client) { try { writer.write('\n'); indent(writer, 1); writer.write('<'); writer.write(TAG_ISSUE); writeAttribute(writer, 2, ATTR_ID, issue.getId()); writeAttribute(writer, 2, ATTR_MESSAGE, message); writer.write(">\n"); Location currentLocation = location; while (currentLocation != null) { // // // // IMPORTANT: Keep this format compatible with the XML report format // encoded by the XmlReporter! That way XML reports and baseline // files can be mix & matched. (Compatible=subset.) // // indent(writer, 2); writer.write('<'); writer.write(TAG_LOCATION); String path = getDisplayPath(project, currentLocation.getFile()); writeAttribute(writer, 3, ATTR_FILE, path); Position start = currentLocation.getStart(); if (start != null) { int line = start.getLine(); if (line >= 0) { // +1: Line numbers internally are 0-based, report should be // 1-based. writeAttribute(writer, 3, ATTR_LINE, Integer.toString(line + 1)); } } writer.write("/>\n"); currentLocation = currentLocation.getSecondary(); } indent(writer, 1); writer.write("\n"); } catch (IOException ioe) { client.log(ioe, null); } } } /** * Entry loaded from the baseline file. Note that for an error with multiple locations, * there may be multiple entries; these are linked by next/previous fields. */ private static class Entry { public final String issueId; public final String message; public final String path; public final String line; /** * An issue can have multiple locations; we create a separate entry for each * but we link them together such that we can mark them all fixed */ public Entry next; public Entry previous; public Entry( @NonNull String issueId, @NonNull String message, @NonNull String path, @Nullable String line) { this.issueId = issueId; this.message = message; this.path = path; this.line = line; } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy