| /* |
| * 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 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; |
| |
| import java.io.BufferedWriter; |
| import java.io.File; |
| import java.io.FileWriter; |
| import java.io.IOException; |
| import java.io.Writer; |
| 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; |
| |
| /** |
| * Default implementation of a {@link Configuration} which reads and writes |
| * configuration data into {@code lint.xml} in the project directory. |
| * <p/> |
| * <b>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.</b> |
| */ |
| @Beta |
| public class DefaultConfiguration extends Configuration { |
| private final LintClient mClient; |
| /** Default name of the configuration file */ |
| public static final String CONFIG_FILE_NAME = "lint.xml"; //$NON-NLS-1$ |
| |
| // Lint XML File |
| @NonNull |
| private static final String TAG_ISSUE = "issue"; //$NON-NLS-1$ |
| @NonNull |
| private static final String ATTR_ID = "id"; //$NON-NLS-1$ |
| @NonNull |
| private static final String ATTR_SEVERITY = "severity"; //$NON-NLS-1$ |
| @NonNull |
| private static final String ATTR_PATH = "path"; //$NON-NLS-1$ |
| @NonNull |
| private static final String ATTR_REGEXP = "regexp"; //$NON-NLS-1$ |
| @NonNull |
| private static final String TAG_IGNORE = "ignore"; //$NON-NLS-1$ |
| @NonNull |
| private static final String VALUE_ALL = "all"; //$NON-NLS-1$ |
| |
| private final Configuration mParent; |
| private final Project mProject; |
| private final File mConfigFile; |
| private boolean mBulkEditing; |
| |
| /** Map from id to list of project-relative paths for suppressed warnings */ |
| private Map<String, List<String>> mSuppressed; |
| |
| /** Map from id to regular expressions. */ |
| @Nullable |
| private Map<String, List<Pattern>> mRegexps; |
| |
| /** |
| * Map from id to custom {@link Severity} override |
| */ |
| private Map<String, Severity> mSeverity; |
| |
| protected DefaultConfiguration( |
| @NonNull LintClient client, |
| @Nullable Project project, |
| @Nullable Configuration parent, |
| @NonNull File configFile) { |
| mClient = client; |
| mProject = project; |
| mParent = parent; |
| mConfigFile = 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 /*project*/, null /*parent*/, lintFile); |
| } |
| |
| @Override |
| public boolean isIgnored( |
| @NonNull Context context, |
| @NonNull Issue issue, |
| @Nullable Location location, |
| @NonNull String message) { |
| ensureInitialized(); |
| |
| String id = issue.getId(); |
| List<String> paths = mSuppressed.get(id); |
| if (paths == null) { |
| paths = mSuppressed.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; |
| } |
| } |
| } |
| |
| if (mRegexps != null) { |
| List<Pattern> regexps = mRegexps.get(id); |
| if (regexps == null) { |
| regexps = mRegexps.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 mParent != null && mParent.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 = mSeverity.get(issue.getId()); |
| if (severity == null) { |
| severity = mSeverity.get(VALUE_ALL); |
| } |
| |
| if (severity != null) { |
| return severity; |
| } |
| |
| if (mParent != null) { |
| return mParent.getSeverity(issue); |
| } |
| |
| return getDefaultSeverity(issue); |
| } |
| |
| private void ensureInitialized() { |
| if (mSuppressed == 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<Issue> getIssues() { |
| return Collections.emptyList(); |
| } |
| }, mClient); |
| mClient.report(new Context(driver, mProject, mProject, mConfigFile), |
| IssueRegistry.LINT_ERROR, |
| mProject.getConfiguration(driver).getSeverity(IssueRegistry.LINT_ERROR), |
| Location.create(mConfigFile), message, TextFormat.RAW); |
| } |
| |
| private void readConfig() { |
| mSuppressed = new HashMap<String, List<String>>(); |
| mSeverity = new HashMap<String, Severity>(); |
| |
| if (!mConfigFile.exists()) { |
| return; |
| } |
| |
| try { |
| Document document = XmlUtils.parseUtfXmlFile(mConfigFile, false); |
| 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<String> 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) { |
| mSeverity.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<String> paths = mSuppressed.get(id); |
| if (paths == null) { |
| paths = new ArrayList<String>(n / 2 + 1); |
| mSuppressed.put(id, paths); |
| } |
| paths.add(path); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| } catch (SAXParseException e) { |
| formatError(e.getMessage()); |
| } catch (Exception e) { |
| mClient.log(e, null); |
| } |
| } |
| |
| @VisibleForTesting |
| @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<String> ids, int n, |
| @NonNull String regexp, boolean silent) { |
| try { |
| if (mRegexps == null) { |
| mRegexps = new HashMap<String, List<Pattern>>(); |
| } |
| Pattern pattern = Pattern.compile(regexp); |
| for (String id : ids) { |
| List<Pattern> paths = mRegexps.get(id); |
| if (paths == null) { |
| paths = new ArrayList<Pattern>(n / 2 + 1); |
| mRegexps.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(mConfigFile.getParentFile(), |
| mConfigFile.getName() + ".new"); //$NON-NLS-1$ |
| |
| Writer writer = new BufferedWriter(new FileWriter(file)); |
| writer.write( |
| "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + //$NON-NLS-1$ |
| "<lint>\n"); //$NON-NLS-1$ |
| |
| if (!mSuppressed.isEmpty() || !mSeverity.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<String> idSet = new HashSet<String>(); |
| for (String id : mSuppressed.keySet()) { |
| idSet.add(id); |
| } |
| for (String id : mSeverity.keySet()) { |
| idSet.add(id); |
| } |
| List<String> ids = new ArrayList<String>(idSet); |
| Collections.sort(ids); |
| |
| for (String id : ids) { |
| writer.write(" <"); //$NON-NLS-1$ |
| writer.write(TAG_ISSUE); |
| writeAttribute(writer, ATTR_ID, id); |
| Severity severity = mSeverity.get(id); |
| if (severity != null) { |
| writeAttribute(writer, ATTR_SEVERITY, |
| severity.name().toLowerCase(Locale.US)); |
| } |
| |
| List<Pattern> regexps = mRegexps != null ? mRegexps.get(id) : null; |
| List<String> paths = mSuppressed.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(" <"); //$NON-NLS-1$ |
| writer.write(TAG_IGNORE); |
| writeAttribute(writer, ATTR_PATH, path.replace('\\', '/')); |
| writer.write(" />\n"); //$NON-NLS-1$ |
| } |
| } |
| if (regexps != null) { |
| for (Pattern regexp : regexps) { |
| writer.write(" <"); //$NON-NLS-1$ |
| writer.write(TAG_IGNORE); |
| writeAttribute(writer, ATTR_REGEXP, regexp.pattern()); |
| writer.write(" />\n"); //$NON-NLS-1$ |
| } |
| } |
| writer.write(" </"); //$NON-NLS-1$ |
| writer.write(TAG_ISSUE); |
| writer.write('>'); |
| writer.write('\n'); |
| } else { |
| writer.write(" />\n"); //$NON-NLS-1$ |
| } |
| } |
| } |
| |
| writer.write("</lint>"); //$NON-NLS-1$ |
| 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(mConfigFile.getParentFile(), |
| mConfigFile.getName() + '~'); //$NON-NLS-1$ |
| if (oldFile.exists()) { |
| oldFile.delete(); |
| } |
| if (mConfigFile.exists()) { |
| mConfigFile.renameTo(oldFile); |
| } |
| boolean ok = file.renameTo(mConfigFile); |
| if (ok && oldFile.exists()) { |
| oldFile.delete(); |
| } |
| } catch (Exception e) { |
| mClient.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()); |
| } |
| } |
| |
| /** |
| * Marks the given issue and file combination as being ignored. |
| * |
| * @param issue the issue to be ignored in the given file |
| * @param file the file to ignore the issue in |
| */ |
| public void ignore(@NonNull Issue issue, @NonNull File file) { |
| ensureInitialized(); |
| |
| String path = mProject != null ? mProject.getRelativePath(file) : file.getPath(); |
| |
| List<String> paths = mSuppressed.get(issue.getId()); |
| if (paths == null) { |
| paths = new ArrayList<String>(); |
| mSuppressed.put(issue.getId(), paths); |
| } |
| paths.add(path); |
| |
| // Keep paths sorted alphabetically; makes XML output stable |
| Collections.sort(paths); |
| |
| if (!mBulkEditing) { |
| writeConfig(); |
| } |
| } |
| |
| @Override |
| public void setSeverity(@NonNull Issue issue, @Nullable Severity severity) { |
| ensureInitialized(); |
| |
| String id = issue.getId(); |
| if (severity == null) { |
| mSeverity.remove(id); |
| } else { |
| mSeverity.put(id, severity); |
| } |
| |
| if (!mBulkEditing) { |
| writeConfig(); |
| } |
| } |
| |
| @Override |
| public void startBulkEditing() { |
| mBulkEditing = true; |
| } |
| |
| @Override |
| public void finishBulkEditing() { |
| mBulkEditing = false; |
| writeConfig(); |
| } |
| |
| @VisibleForTesting |
| File getConfigFile() { |
| return mConfigFile; |
| } |
| } |