blob: 4117067dc5db171a03cce6cfb6780eca7e91f698 [file] [log] [blame]
/*
* 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.checks;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_REF_PREFIX;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.RESOURCE_CLR_STYLEABLE;
import static com.android.SdkConstants.RESOURCE_CLZ_ARRAY;
import static com.android.SdkConstants.RESOURCE_CLZ_ID;
import static com.android.SdkConstants.R_ATTR_PREFIX;
import static com.android.SdkConstants.R_CLASS;
import static com.android.SdkConstants.R_ID_PREFIX;
import static com.android.SdkConstants.R_PREFIX;
import static com.android.SdkConstants.TAG_ARRAY;
import static com.android.SdkConstants.TAG_INTEGER_ARRAY;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_PLURALS;
import static com.android.SdkConstants.TAG_RESOURCES;
import static com.android.SdkConstants.TAG_STRING_ARRAY;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.utils.SdkUtils.getResourceFieldName;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.resources.ResourceType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.Speed;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.collect.Lists;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.ast.AstVisitor;
import lombok.ast.ClassDeclaration;
import lombok.ast.ForwardingAstVisitor;
import lombok.ast.NormalTypeBody;
import lombok.ast.VariableDeclaration;
import lombok.ast.VariableDefinition;
/**
* Finds unused resources.
* <p>
* Note: This detector currently performs *string* analysis to check Java files.
* The Lint API needs an official Java AST API (or map to an existing one like
* BCEL for bytecode analysis etc) and once it does this should be updated to
* use it.
*/
public class UnusedResourceDetector extends ResourceXmlDetector implements Detector.JavaScanner {
private static final Implementation IMPLEMENTATION = new Implementation(
UnusedResourceDetector.class,
EnumSet.of(Scope.MANIFEST, Scope.ALL_RESOURCE_FILES, Scope.ALL_JAVA_FILES,
Scope.TEST_SOURCES));
/** Unused resources (other than ids). */
public static final Issue ISSUE = Issue.create(
"UnusedResources", //$NON-NLS-1$
"Unused resources",
"Unused resources make applications larger and slow down builds.",
Category.PERFORMANCE,
3,
Severity.WARNING,
IMPLEMENTATION);
/** Unused id's */
public static final Issue ISSUE_IDS = Issue.create(
"UnusedIds", //$NON-NLS-1$
"Unused id",
"This resource id definition appears not to be needed since it is not referenced " +
"from anywhere. Having id definitions, even if unused, is not necessarily a bad " +
"idea since they make working on layouts and menus easier, so there is not a " +
"strong reason to delete these.",
Category.PERFORMANCE,
1,
Severity.WARNING,
IMPLEMENTATION)
.setEnabledByDefault(false);
private Set<String> mDeclarations;
private Set<String> mReferences;
private Map<String, Location> mUnused;
/**
* Constructs a new {@link UnusedResourceDetector}
*/
public UnusedResourceDetector() {
}
@Override
public void run(@NonNull Context context) {
assert false;
}
@Override
public boolean appliesTo(@NonNull Context context, @NonNull File file) {
return true;
}
@Override
public void beforeCheckProject(@NonNull Context context) {
if (context.getPhase() == 1) {
mDeclarations = new HashSet<String>(300);
mReferences = new HashSet<String>(300);
}
}
// ---- Implements JavaScanner ----
@Override
public void beforeCheckFile(@NonNull Context context) {
File file = context.file;
boolean isXmlFile = LintUtils.isXmlFile(file);
if (isXmlFile || LintUtils.isBitmapFile(file)) {
String fileName = file.getName();
String parentName = file.getParentFile().getName();
int dash = parentName.indexOf('-');
String typeName = parentName.substring(0, dash == -1 ? parentName.length() : dash);
ResourceType type = ResourceType.getEnum(typeName);
if (type != null && LintUtils.isFileBasedResourceType(type)) {
String baseName = fileName.substring(0, fileName.length() - DOT_XML.length());
String resource = R_PREFIX + typeName + '.' + baseName;
if (context.getPhase() == 1) {
mDeclarations.add(resource);
} else {
assert context.getPhase() == 2;
if (mUnused.containsKey(resource)) {
// Check whether this is an XML document that has a tools:ignore attribute
// on the document element: if so don't record it as a declaration.
if (isXmlFile && context instanceof XmlContext) {
XmlContext xmlContext = (XmlContext) context;
if (xmlContext.document != null
&& xmlContext.document.getDocumentElement() != null) {
Element root = xmlContext.document.getDocumentElement();
if (xmlContext.getDriver().isSuppressed(xmlContext, ISSUE, root)) {
// Also remove it from consideration such that even the
// presence of this field in the R file is ignored.
mUnused.remove(resource);
return;
}
}
}
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
mUnused.remove(resource);
return;
}
recordLocation(resource, Location.create(file));
}
}
}
}
}
@Override
public void afterCheckProject(@NonNull Context context) {
if (context.getPhase() == 1) {
mDeclarations.removeAll(mReferences);
Set<String> unused = mDeclarations;
mReferences = null;
mDeclarations = null;
// Remove styles and attributes: they may be used, analysis isn't complete for these
List<String> styles = new ArrayList<String>();
for (String resource : unused) {
// R.style.x, R.styleable.x, R.attr
if (resource.startsWith("R.style") //$NON-NLS-1$
|| resource.startsWith("R.attr")) { //$NON-NLS-1$
styles.add(resource);
}
}
unused.removeAll(styles);
// Remove id's if the user has disabled reporting issue ids
if (!unused.isEmpty() && !context.isEnabled(ISSUE_IDS)) {
// Remove all R.id references
List<String> ids = new ArrayList<String>();
for (String resource : unused) {
if (resource.startsWith(R_ID_PREFIX)) {
ids.add(resource);
}
}
unused.removeAll(ids);
}
if (!unused.isEmpty() && !context.getDriver().hasParserErrors()) {
mUnused = new HashMap<String, Location>(unused.size());
for (String resource : unused) {
mUnused.put(resource, null);
}
// Request another pass, and in the second pass we'll gather location
// information for all declaration locations we've found
context.requestRepeat(this, Scope.ALL_RESOURCES_SCOPE);
}
} else {
assert context.getPhase() == 2;
// Report any resources that we (for some reason) could not find a declaration
// location for
if (!mUnused.isEmpty()) {
// Fill in locations for files that we didn't encounter in other ways
for (Map.Entry<String, Location> entry : mUnused.entrySet()) {
String resource = entry.getKey();
Location location = entry.getValue();
//noinspection VariableNotUsedInsideIf
if (location != null) {
continue;
}
// Try to figure out the file if it's a file based resource (such as R.layout) --
// in that case we can figure out the filename since it has a simple mapping
// from the resource name (though the presence of qualifiers like -land etc
// makes it a little tricky if there's no base file provided)
int secondDot = resource.indexOf('.', 2);
String typeName = resource.substring(2, secondDot); // 2: Skip R.
ResourceType type = ResourceType.getEnum(typeName);
if (type != null && LintUtils.isFileBasedResourceType(type)) {
String name = resource.substring(secondDot + 1);
List<File> folders = Lists.newArrayList();
List<File> resourceFolders = context.getProject().getResourceFolders();
for (File res : resourceFolders) {
File[] f = res.listFiles();
if (f != null) {
folders.addAll(Arrays.asList(f));
}
}
if (folders != null) {
// Process folders in alphabetical order such that we process
// based folders first: we want the locations in base folder
// order
Collections.sort(folders, new Comparator<File>() {
@Override
public int compare(File file1, File file2) {
return file1.getName().compareTo(file2.getName());
}
});
for (File folder : folders) {
if (folder.getName().startsWith(typeName)) {
File[] files = folder.listFiles();
if (files != null) {
Arrays.sort(files);
for (File file : files) {
String fileName = file.getName();
if (fileName.startsWith(name)
&& fileName.startsWith(".", //$NON-NLS-1$
name.length())) {
recordLocation(resource, Location.create(file));
}
}
}
}
}
}
}
}
List<String> sorted = new ArrayList<String>(mUnused.keySet());
Collections.sort(sorted);
Boolean skippedLibraries = null;
for (String resource : sorted) {
Location location = mUnused.get(resource);
if (location != null) {
// We were prepending locations, but we want to prefer the base folders
location = Location.reverse(location);
}
if (location == null) {
if (skippedLibraries == null) {
skippedLibraries = false;
for (Project project : context.getDriver().getProjects()) {
if (!project.getReportIssues()) {
skippedLibraries = true;
break;
}
}
}
if (skippedLibraries) {
// Skip this resource if we don't have a location, and one or
// more library projects were skipped; the resource was very
// probably defined in that library project and only encountered
// in the main project's java R file
continue;
}
}
String message = String.format("The resource `%1$s` appears to be unused",
resource);
Issue issue = getIssue(resource);
// TODO: Compute applicable node scope
context.report(issue, location, message);
}
}
}
}
private static Issue getIssue(String resource) {
return resource.startsWith(R_ID_PREFIX) ? ISSUE_IDS : ISSUE;
}
private void recordLocation(String resource, Location location) {
Location oldLocation = mUnused.get(resource);
if (oldLocation != null) {
location.setSecondary(oldLocation);
}
mUnused.put(resource, location);
}
@Override
public Collection<String> getApplicableAttributes() {
return ALL;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
TAG_STYLE,
TAG_RESOURCES,
TAG_ARRAY,
TAG_STRING_ARRAY,
TAG_INTEGER_ARRAY,
TAG_PLURALS
);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
if (TAG_RESOURCES.equals(element.getTagName())) {
for (Element item : LintUtils.getChildren(element)) {
Attr nameAttribute = item.getAttributeNode(ATTR_NAME);
if (nameAttribute != null) {
String name = getResourceFieldName(nameAttribute.getValue());
String type = item.getTagName();
if (type.equals(TAG_ITEM)) {
type = item.getAttribute(ATTR_TYPE);
if (type == null || type.isEmpty()) {
type = RESOURCE_CLZ_ID;
}
} else if (type.equals("declare-styleable")) { //$NON-NLS-1$
type = RESOURCE_CLR_STYLEABLE;
} else if (type.contains("array")) { //$NON-NLS-1$
// <string-array> etc
type = RESOURCE_CLZ_ARRAY;
}
String resource = R_PREFIX + type + '.' + name;
if (context.getPhase() == 1) {
mDeclarations.add(resource);
checkChildRefs(item);
} else {
assert context.getPhase() == 2;
if (mUnused.containsKey(resource)) {
if (context.getDriver().isSuppressed(context, getIssue(resource),
item)) {
mUnused.remove(resource);
continue;
}
if (!context.getProject().getReportIssues()) {
mUnused.remove(resource);
continue;
}
if (isAnalyticsFile(context)) {
mUnused.remove(resource);
continue;
}
recordLocation(resource, context.getLocation(nameAttribute));
}
}
}
}
} else //noinspection VariableNotUsedInsideIf
if (mReferences != null) {
assert TAG_STYLE.equals(element.getTagName())
|| TAG_ARRAY.equals(element.getTagName())
|| TAG_PLURALS.equals(element.getTagName())
|| TAG_INTEGER_ARRAY.equals(element.getTagName())
|| TAG_STRING_ARRAY.equals(element.getTagName());
for (Element item : LintUtils.getChildren(element)) {
checkChildRefs(item);
}
}
}
private static final String ANALYTICS_FILE = "analytics.xml"; //$NON-NLS-1$
/**
* Returns true if this XML file corresponds to an Analytics configuration file;
* these contain some attributes read by the library which won't be flagged as
* used by the application
*
* @param context the context used for scanning
* @return true if the file represents an analytics file
*/
public static boolean isAnalyticsFile(Context context) {
File file = context.file;
return file.getPath().endsWith(ANALYTICS_FILE) && file.getName().equals(ANALYTICS_FILE);
}
private void checkChildRefs(Element item) {
// Look for ?attr/ and @dimen/foo etc references in the item children
NodeList childNodes = item.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
String text = child.getNodeValue();
int index = text.indexOf(ATTR_REF_PREFIX);
if (index != -1) {
String name = text.substring(index + ATTR_REF_PREFIX.length()).trim();
mReferences.add(R_ATTR_PREFIX + name);
} else {
index = text.indexOf('@');
if (index != -1 && text.indexOf('/', index) != -1
&& !text.startsWith("@android:", index)) { //$NON-NLS-1$
// Compute R-string, e.g. @string/foo => R.string.foo
String token = text.substring(index + 1).trim().replace('/', '.');
String r = R_PREFIX + token;
mReferences.add(r);
}
}
}
}
}
@Override
public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
String value = attribute.getValue();
if (value.startsWith("@+") && !value.startsWith("@+android")) { //$NON-NLS-1$ //$NON-NLS-2$
String resource = R_PREFIX + value.substring(2).replace('/', '.');
// We already have the declarations when we scan the R file, but we're tracking
// these here to get attributes for position info
if (context.getPhase() == 1) {
mDeclarations.add(resource);
} else if (mUnused.containsKey(resource)) {
if (context.getDriver().isSuppressed(context, getIssue(resource), attribute)) {
mUnused.remove(resource);
return;
}
if (!context.getProject().getReportIssues()) {
mUnused.remove(resource);
return;
}
recordLocation(resource, context.getLocation(attribute));
return;
}
} else if (mReferences != null) {
if (value.startsWith("@") //$NON-NLS-1$
&& !value.startsWith("@android:")) { //$NON-NLS-1$
// Compute R-string, e.g. @string/foo => R.string.foo
String r = R_PREFIX + value.substring(1).replace('/', '.');
mReferences.add(r);
} else if (value.startsWith(ATTR_REF_PREFIX)) {
mReferences.add(R_ATTR_PREFIX + value.substring(ATTR_REF_PREFIX.length()));
}
}
if (attribute.getNamespaceURI() != null
&& !ANDROID_URI.equals(attribute.getNamespaceURI()) && mReferences != null) {
mReferences.add(R_ATTR_PREFIX + attribute.getLocalName());
}
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.SLOW;
}
@Override
public List<Class<? extends lombok.ast.Node>> getApplicableNodeTypes() {
return Collections.<Class<? extends lombok.ast.Node>>singletonList(ClassDeclaration.class);
}
@Override
public boolean appliesToResourceRefs() {
return true;
}
@Override
public void visitResourceReference(@NonNull JavaContext context, @Nullable AstVisitor visitor,
@NonNull lombok.ast.Node node, @NonNull String type, @NonNull String name,
boolean isFramework) {
if (mReferences != null && !isFramework) {
String reference = R_PREFIX + type + '.' + name;
mReferences.add(reference);
}
}
@Override
public AstVisitor createJavaVisitor(@NonNull JavaContext context) {
if (mReferences != null) {
return new UnusedResourceVisitor();
} else {
// Second pass, computing resource declaration locations: No need to look at Java
return null;
}
}
// Look for references and declarations
private class UnusedResourceVisitor extends ForwardingAstVisitor {
@Override
public boolean visitClassDeclaration(ClassDeclaration node) {
// Look for declarations of R class fields and store them in
// mDeclarations
String description = node.astName().astValue();
if (description.equals(R_CLASS)) {
// This is an R class. We can process this class very deliberately.
// The R class has a very specific AST format:
// ClassDeclaration ("R")
// NormalTypeBody
// ClassDeclaration (e.g. "drawable")
// NormalTypeBody
// VariableDeclaration
// VariableDefinition (e.g. "ic_launcher")
for (lombok.ast.Node body : node.getChildren()) {
if (body instanceof NormalTypeBody) {
for (lombok.ast.Node subclass : body.getChildren()) {
if (subclass instanceof ClassDeclaration) {
String className = ((ClassDeclaration) subclass).astName().astValue();
for (lombok.ast.Node innerBody : subclass.getChildren()) {
if (innerBody instanceof NormalTypeBody) {
for (lombok.ast.Node field : innerBody.getChildren()) {
if (field instanceof VariableDeclaration) {
for (lombok.ast.Node child : field.getChildren()) {
if (child instanceof VariableDefinition) {
VariableDefinition def =
(VariableDefinition) child;
String name = def.astVariables().first()
.astName().astValue();
String resource = R_PREFIX + className
+ '.' + name;
mDeclarations.add(resource);
} // Else: It could be a comment node
}
}
}
}
}
}
}
}
}
return true;
}
return false;
}
}
}