com.android.tools.lint.client.api.DefaultConfiguration Maven / Gradle / Ivy
/*
* Copyright (C) 2011 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 static com.android.SdkConstants.CURRENT_PLATFORM;
import static com.android.SdkConstants.PLATFORM_WINDOWS;
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.Location;
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.annotations.Beta;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXParseException;
/**
* Default implementation of a {@link Configuration} which reads and writes
* configuration data into {@code lint.xml} in the project directory.
*
* NOTE: This is not a public or final API; if you rely on this be prepared
* to adjust your code for the next tools release.
*/
@Beta
public class DefaultConfiguration extends Configuration {
private final LintClient client;
/** Default name of the configuration file */
public static final String CONFIG_FILE_NAME = "lint.xml";
// Lint XML File
/** The root tag in a configuration file */
public static final String TAG_LINT = "lint";
private static final String TAG_ISSUE = "issue";
private static final String ATTR_ID = "id";
private static final String ATTR_SEVERITY = "severity";
private static final String ATTR_PATH = "path";
private static final String ATTR_REGEXP = "regexp";
private static final String TAG_IGNORE = "ignore";
public static final String VALUE_ALL = "all";
private static final String ATTR_BASELINE = "baseline";
private static final String RES_PATH_START = "res/";
private static final int RES_PATH_START_LEN = RES_PATH_START.length();
private final Configuration parent;
private final Project project;
private final File configFile;
private boolean bulkEditing;
private File baselineFile;
/** Map from id to list of project-relative paths for suppressed warnings */
private Map> suppressed;
/** Map from id to regular expressions. */
@Nullable
private Map> regexps;
/**
* Map from id to custom {@link Severity} override
*/
protected Map severity;
protected DefaultConfiguration(
@NonNull LintClient client,
@Nullable Project project,
@Nullable Configuration parent,
@NonNull File configFile) {
this.client = client;
this.project = project;
this.parent = parent;
this.configFile = configFile;
}
protected DefaultConfiguration(
@NonNull LintClient client,
@NonNull Project project,
@Nullable Configuration parent) {
this(client, project, parent, new File(project.getDir(), CONFIG_FILE_NAME));
}
/**
* Creates a new {@link DefaultConfiguration}
*
* @param client the client to report errors to etc
* @param project the associated project
* @param parent the parent/fallback configuration or null
* @return a new configuration
*/
@NonNull
public static DefaultConfiguration create(
@NonNull LintClient client,
@NonNull Project project,
@Nullable Configuration parent) {
return new DefaultConfiguration(client, project, parent);
}
/**
* Creates a new {@link DefaultConfiguration} for the given lint config
* file, not affiliated with a project. This is used for global
* configurations.
*
* @param client the client to report errors to etc
* @param lintFile the lint file containing the configuration
* @return a new configuration
*/
@NonNull
public static DefaultConfiguration create(@NonNull LintClient client, @NonNull File lintFile) {
return new DefaultConfiguration(client, null, null, lintFile);
}
@Override
public boolean isIgnored(
@NonNull Context context,
@NonNull Issue issue,
@Nullable Location location,
@NonNull String message) {
ensureInitialized();
String id = issue.getId();
List paths = suppressed.get(id);
if (paths == null) {
paths = suppressed.get(VALUE_ALL);
}
if (paths != null && location != null) {
File file = location.getFile();
String relativePath = context.getProject().getRelativePath(file);
for (String suppressedPath : paths) {
if (suppressedPath.equals(relativePath)) {
return true;
}
// Also allow a prefix
if (relativePath.startsWith(suppressedPath)) {
return true;
}
}
// A project can have multiple resources folders. The code before this
// only checks for paths relative to project root (which doesn't work for paths such as
// res/layout/foo.xml defined in lint.xml - when using gradle where the
// resource directory points to src/main/res)
// Here we check if any of the suppressed paths are relative to the resource folders
// of a project.
Set suppressedPathSet = paths.stream()
.filter(p -> p.startsWith(RES_PATH_START))
.map(p -> Paths.get(p.substring(RES_PATH_START_LEN)))
.collect(Collectors.toSet());
if (!suppressedPathSet.isEmpty()) {
Path toCheck = file.toPath();
// Is it relative to any of the resource folders?
for (File resDir : context.getProject().getResourceFolders()) {
Path path = resDir.toPath();
Path relative = path.relativize(toCheck);
if (suppressedPathSet.contains(relative)) {
return true;
}
// Allow suppress the relativePath if it is a prefix
if (suppressedPathSet.stream().anyMatch(relative::startsWith)) {
return true;
}
}
}
}
if (regexps != null) {
List regexps = this.regexps.get(id);
if (regexps == null) {
regexps = this.regexps.get(VALUE_ALL);
}
if (regexps != null && location != null) {
// Check message
for (Pattern regexp : regexps) {
Matcher matcher = regexp.matcher(message);
if (matcher.find()) {
return true;
}
}
// Check location
File file = location.getFile();
String relativePath = context.getProject().getRelativePath(file);
boolean checkUnixPath = false;
for (Pattern regexp : regexps) {
Matcher matcher = regexp.matcher(relativePath);
if (matcher.find()) {
return true;
} else if (regexp.pattern().indexOf('/') != -1) {
checkUnixPath = true;
}
}
if (checkUnixPath && CURRENT_PLATFORM == PLATFORM_WINDOWS) {
relativePath = relativePath.replace('\\', '/');
for (Pattern regexp : regexps) {
Matcher matcher = regexp.matcher(relativePath);
if (matcher.find()) {
return true;
}
}
}
}
}
return parent != null && parent.isIgnored(context, issue, location, message);
}
@NonNull
protected Severity getDefaultSeverity(@NonNull Issue issue) {
if (!issue.isEnabledByDefault()) {
return Severity.IGNORE;
}
return issue.getDefaultSeverity();
}
@Override
@NonNull
public Severity getSeverity(@NonNull Issue issue) {
ensureInitialized();
Severity severity = this.severity.get(issue.getId());
if (severity == null) {
severity = this.severity.get(VALUE_ALL);
}
if (severity != null) {
return severity;
}
if (parent != null) {
return parent.getSeverity(issue);
}
return getDefaultSeverity(issue);
}
private void ensureInitialized() {
if (suppressed == null) {
readConfig();
}
}
private void formatError(String message, Object... args) {
if (args != null && args.length > 0) {
message = String.format(message, args);
}
message = "Failed to parse `lint.xml` configuration file: " + message;
LintDriver driver = new LintDriver(new IssueRegistry() {
@Override @NonNull public List getIssues() {
return Collections.emptyList();
}
}, client);
client.report(new Context(driver, project, project, configFile),
IssueRegistry.LINT_ERROR,
project.getConfiguration(driver).getSeverity(IssueRegistry.LINT_ERROR),
Location.create(configFile), message, TextFormat.RAW);
}
private void readConfig() {
suppressed = new HashMap<>();
severity = new HashMap<>();
if (!configFile.exists()) {
return;
}
try {
// TODO: Switch to a pull parser!
Document document = XmlUtils.parseUtfXmlFile(configFile, false);
String baseline = document.getDocumentElement().getAttribute(ATTR_BASELINE);
if (!baseline.isEmpty()) {
baselineFile = new File(baseline.replace('/', File.separatorChar));
if (!baselineFile.isAbsolute()) {
baselineFile = new File(project.getDir(), baselineFile.getPath());
}
}
NodeList issues = document.getElementsByTagName(TAG_ISSUE);
Splitter splitter = Splitter.on(',').trimResults().omitEmptyStrings();
for (int i = 0, count = issues.getLength(); i < count; i++) {
Node node = issues.item(i);
Element element = (Element) node;
String idList = element.getAttribute(ATTR_ID);
if (idList.isEmpty()) {
formatError("Invalid lint config file: Missing required issue id attribute");
continue;
}
Iterable ids = splitter.split(idList);
NamedNodeMap attributes = node.getAttributes();
for (int j = 0, n = attributes.getLength(); j < n; j++) {
Node attribute = attributes.item(j);
String name = attribute.getNodeName();
String value = attribute.getNodeValue();
if (ATTR_ID.equals(name)) {
// already handled
} else if (ATTR_SEVERITY.equals(name)) {
for (Severity severity : Severity.values()) {
if (value.equalsIgnoreCase(severity.name())) {
for (String id : ids) {
this.severity.put(id, severity);
}
break;
}
}
} else {
formatError("Unexpected attribute \"%1$s\"", name);
}
}
// Look up ignored errors
NodeList childNodes = element.getChildNodes();
if (childNodes.getLength() > 0) {
for (int j = 0, n = childNodes.getLength(); j < n; j++) {
Node child = childNodes.item(j);
if (child.getNodeType() == Node.ELEMENT_NODE) {
Element ignore = (Element) child;
String path = ignore.getAttribute(ATTR_PATH);
if (path.isEmpty()) {
String regexp = ignore.getAttribute(ATTR_REGEXP);
if (regexp.isEmpty()) {
formatError("Missing required attribute %1$s or %2$s under %3$s",
ATTR_PATH, ATTR_REGEXP, idList);
} else {
addRegexp(idList, ids, n, regexp, false);
}
} else {
// Normalize path format to File.separator. Also
// handle the file format containing / or \.
if (File.separatorChar == '/') {
path = path.replace('\\', '/');
} else {
path = path.replace('/', File.separatorChar);
}
if (path.indexOf('*') != -1) {
String regexp = globToRegexp(path);
addRegexp(idList, ids, n, regexp, false);
} else {
for (String id : ids) {
List paths = suppressed.get(id);
if (paths == null) {
paths = new ArrayList<>(n / 2 + 1);
suppressed.put(id, paths);
}
paths.add(path);
}
}
}
}
}
}
}
} catch (SAXParseException e) {
formatError(e.getMessage());
} catch (Exception e) {
client.log(e, null);
}
}
@NonNull
public static String globToRegexp(@NonNull String glob) {
StringBuilder sb = new StringBuilder(glob.length() * 2);
int begin = 0;
sb.append('^');
for (int i = 0, n = glob.length(); i < n; i++) {
char c = glob.charAt(i);
if (c == '*') {
begin = appendQuoted(sb, glob, begin, i) + 1;
if (i < n - 1 && glob.charAt(i + 1) == '*') {
i++;
begin++;
}
sb.append(".*?");
} else if (c == '?') {
begin = appendQuoted(sb, glob, begin, i) + 1;
sb.append(".?");
}
}
appendQuoted(sb, glob, begin, glob.length());
sb.append('$');
return sb.toString();
}
private static int appendQuoted(StringBuilder sb, String s, int from, int to) {
if (to > from) {
boolean isSimple = true;
for (int i = from; i < to; i++) {
char c = s.charAt(i);
if (!Character.isLetterOrDigit(c) && c != '/' && c != ' ') {
isSimple = false;
break;
}
}
if (isSimple) {
for (int i = from; i < to; i++) {
sb.append(s.charAt(i));
}
return to;
}
sb.append(Pattern.quote(s.substring(from, to)));
}
return to;
}
private void addRegexp(@NonNull String idList, @NonNull Iterable ids, int n,
@NonNull String regexp, boolean silent) {
try {
if (regexps == null) {
regexps = new HashMap<>();
}
Pattern pattern = Pattern.compile(regexp);
for (String id : ids) {
List paths = regexps.get(id);
if (paths == null) {
paths = new ArrayList<>(n / 2 + 1);
regexps.put(id, paths);
}
paths.add(pattern);
}
} catch (PatternSyntaxException e) {
if (!silent) {
formatError("Invalid pattern %1$s under %2$s: %3$s",
regexp, idList, e.getDescription());
}
}
}
private void writeConfig() {
try {
// Write the contents to a new file first such that we don't clobber the
// existing file if some I/O error occurs.
File file = new File(configFile.getParentFile(),
configFile.getName() + ".new");
Writer writer = new BufferedWriter(new FileWriter(file));
writer.write(
"\n" +
"<");
writer.write(TAG_LINT);
if (baselineFile != null) {
writer.write(" baseline=\"");
String path = project != null ?
project.getRelativePath(baselineFile) : baselineFile.getPath();
writeAttribute(writer, ATTR_BASELINE, path.replace('\\', '/'));
}
writer.write(">\n");
if (!suppressed.isEmpty() || !severity.isEmpty()) {
// Process the maps in a stable sorted order such that if the
// files are checked into version control with the project,
// there are no random diffs just because hashing algorithms
// differ:
Set idSet = new HashSet<>();
for (String id : suppressed.keySet()) {
idSet.add(id);
}
for (String id : severity.keySet()) {
idSet.add(id);
}
List ids = new ArrayList<>(idSet);
Collections.sort(ids);
for (String id : ids) {
writer.write(" <");
writer.write(TAG_ISSUE);
writeAttribute(writer, ATTR_ID, id);
Severity severity = this.severity.get(id);
if (severity != null) {
writeAttribute(writer, ATTR_SEVERITY,
severity.name().toLowerCase(Locale.US));
}
List regexps = this.regexps != null ? this.regexps.get(id) : null;
List paths = suppressed.get(id);
if (paths != null && !paths.isEmpty()
|| regexps != null && !regexps.isEmpty()) {
writer.write('>');
writer.write('\n');
// The paths are already kept in sorted order when they are modified
// by ignore(...)
if (paths != null) {
for (String path : paths) {
writer.write(" <");
writer.write(TAG_IGNORE);
writeAttribute(writer, ATTR_PATH, path.replace('\\', '/'));
writer.write(" />\n");
}
}
if (regexps != null) {
for (Pattern regexp : regexps) {
writer.write(" <");
writer.write(TAG_IGNORE);
writeAttribute(writer, ATTR_REGEXP, regexp.pattern());
writer.write(" />\n");
}
}
writer.write(" ");
writer.write(TAG_ISSUE);
writer.write('>');
writer.write('\n');
} else {
writer.write(" />\n");
}
}
}
writer.write("\n");
writer.close();
// Move file into place: move current version to lint.xml~ (removing the old ~ file
// if it exists), then move the new version to lint.xml.
File oldFile = new File(configFile.getParentFile(),
configFile.getName() + '~');
if (oldFile.exists()) {
oldFile.delete();
}
if (configFile.exists()) {
configFile.renameTo(oldFile);
}
boolean ok = file.renameTo(configFile);
if (ok && oldFile.exists()) {
oldFile.delete();
}
} catch (Exception e) {
client.log(e, null);
}
}
private static void writeAttribute(
@NonNull Writer writer, @NonNull String name, @NonNull String value)
throws IOException {
writer.write(' ');
writer.write(name);
writer.write('=');
writer.write('"');
writer.write(value);
writer.write('"');
}
@Override
public void ignore(
@NonNull Context context,
@NonNull Issue issue,
@Nullable Location location,
@NonNull String message) {
// This configuration only supports suppressing warnings on a per-file basis
if (location != null) {
ignore(issue, location.getFile());
}
}
@Override
public void ignore(@NonNull Issue issue, @NonNull File file) {
ensureInitialized();
String path = project != null ? project.getRelativePath(file) : file.getPath();
List paths = suppressed.get(issue.getId());
if (paths == null) {
paths = new ArrayList<>();
suppressed.put(issue.getId(), paths);
}
paths.add(path);
// Keep paths sorted alphabetically; makes XML output stable
Collections.sort(paths);
if (!bulkEditing) {
writeConfig();
}
}
@Override
public void setSeverity(@NonNull Issue issue, @Nullable Severity severity) {
ensureInitialized();
String id = issue.getId();
if (severity == null) {
this.severity.remove(id);
} else {
this.severity.put(id, severity);
}
if (!bulkEditing) {
writeConfig();
}
}
@Override
public void startBulkEditing() {
bulkEditing = true;
}
@Override
public void finishBulkEditing() {
bulkEditing = false;
writeConfig();
}
@VisibleForTesting
File getConfigFile() {
return configFile;
}
@Override
@Nullable
public File getBaselineFile() {
if (baselineFile != null) {
if (project != null && !baselineFile.isAbsolute()) {
return new File(project.getDir(), baselineFile.getPath());
}
}
return baselineFile;
}
@Override
public void setBaselineFile(@Nullable File baselineFile) {
this.baselineFile = baselineFile;
}
}