/* * 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; } } }