blob: 6041f63006298c6b148f48a3b8bda62b314d6ec6 [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_ID;
import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.FD_RES_VALUES;
import static com.android.SdkConstants.ID_PREFIX;
import static com.android.SdkConstants.NEW_ID_PREFIX;
import static com.android.SdkConstants.RELATIVE_LAYOUT;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.VALUE_ID;
import static com.android.tools.lint.detector.api.LintUtils.editDistance;
import static com.android.tools.lint.detector.api.LintUtils.getChildren;
import static com.android.tools.lint.detector.api.LintUtils.isSameResourceFile;
import static com.android.tools.lint.detector.api.LintUtils.stripIdPrefix;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.res2.AbstractResourceRepository;
import com.android.ide.common.res2.ResourceFile;
import com.android.ide.common.res2.ResourceItem;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Location.Handle;
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.android.utils.Pair;
import com.google.common.base.Joiner;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Checks for duplicate ids within a layout and within an included layout
*/
public class WrongIdDetector extends LayoutDetector {
private static final Implementation IMPLEMENTATION = new Implementation(
WrongIdDetector.class,
Scope.RESOURCE_FILE_SCOPE);
/** Ids bound to widgets in any of the layout files */
private final Set<String> mGlobalIds = new HashSet<String>(100);
/** Ids bound to widgets in the current layout file */
private Set<String> mFileIds;
/** Ids declared in a value's file, e.g. {@code <item type="id" name="foo"/>} */
private Set<String> mDeclaredIds;
/**
* Location handles for the various id references that were not found as
* defined in the same layout, to be checked after the whole project has
* been scanned
*/
private List<Pair<String, Location.Handle>> mHandles;
/** List of RelativeLayout elements in the current layout */
private List<Element> mRelativeLayouts;
/** Reference to an unknown id */
@SuppressWarnings("unchecked")
public static final Issue UNKNOWN_ID = Issue.create(
"UnknownId", //$NON-NLS-1$
"Reference to an unknown id",
"The `@+id/` syntax refers to an existing id, or creates a new one if it has " +
"not already been defined elsewhere. However, this means that if you have a " +
"typo in your reference, or if the referred view no longer exists, you do not " +
"get a warning since the id will be created on demand. This check catches " +
"errors where you have renamed an id without updating all of the references to " +
"it.",
Category.CORRECTNESS,
8,
Severity.FATAL,
new Implementation(
WrongIdDetector.class,
Scope.ALL_RESOURCES_SCOPE,
Scope.RESOURCE_FILE_SCOPE));
/** Reference to an id that is not a sibling */
public static final Issue NOT_SIBLING = Issue.create(
"NotSibling", //$NON-NLS-1$
"RelativeLayout Invalid Constraints",
"Layout constraints in a given `RelativeLayout` should reference other views " +
"within the same relative layout (but not itself!)",
Category.CORRECTNESS,
6,
Severity.FATAL,
IMPLEMENTATION);
/** An ID declaration which is not valid */
public static final Issue INVALID = Issue.create(
"InvalidId", //$NON-NLS-1$
"Invalid ID declaration",
"An id definition *must* be of the form `@+id/yourname`. The tools have not " +
"rejected strings of the form `@+foo/bar` in the past, but that was an error, " +
"and could lead to tricky errors because of the way the id integers are assigned.\n" +
"\n" +
"If you really want to have different \"scopes\" for your id's, use prefixes " +
"instead, such as `login_button1` and `login_button2`.",
Category.CORRECTNESS,
6,
Severity.FATAL,
IMPLEMENTATION);
/** Reference to an id that is not in the current layout */
public static final Issue UNKNOWN_ID_LAYOUT = Issue.create(
"UnknownIdInLayout", //$NON-NLS-1$
"Reference to an id that is not in the current layout",
"The `@+id/` syntax refers to an existing id, or creates a new one if it has " +
"not already been defined elsewhere. However, this means that if you have a " +
"typo in your reference, or if the referred view no longer exists, you do not " +
"get a warning since the id will be created on demand.\n" +
"\n" +
"This is sometimes intentional, for example where you are referring to a view " +
"which is provided in a different layout via an include. However, it is usually " +
"an accident where you have a typo or you have renamed a view without updating " +
"all the references to it.",
Category.CORRECTNESS,
5,
Severity.WARNING,
new Implementation(
WrongIdDetector.class,
Scope.RESOURCE_FILE_SCOPE));
/** Constructs a duplicate id check */
public WrongIdDetector() {
}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.VALUES;
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public Collection<String> getApplicableAttributes() {
return Collections.singletonList(ATTR_ID);
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(RELATIVE_LAYOUT, TAG_ITEM);
}
@Override
public void beforeCheckFile(@NonNull Context context) {
mFileIds = new HashSet<String>();
mRelativeLayouts = null;
}
@Override
public void afterCheckFile(@NonNull Context context) {
if (mRelativeLayouts != null) {
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
for (Element layout : mRelativeLayouts) {
List<Element> children = getChildren(layout);
Set<String> ids = Sets.newHashSetWithExpectedSize(children.size());
for (Element child : children) {
String id = child.getAttributeNS(ANDROID_URI, ATTR_ID);
if (id != null && !id.isEmpty()) {
ids.add(id);
}
}
for (Element element : children) {
String selfId = stripIdPrefix(element.getAttributeNS(ANDROID_URI, ATTR_ID));
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attr = (Attr) attributes.item(i);
String value = attr.getValue();
if ((value.startsWith(NEW_ID_PREFIX) ||
value.startsWith(ID_PREFIX))
&& ANDROID_URI.equals(attr.getNamespaceURI())
&& attr.getLocalName().startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) {
if (!idDefined(mFileIds, value)) {
// Stash a reference to this id and location such that
// we can check after the *whole* layout has been processed,
// since it's too early to conclude here that the id does
// not exist (you are allowed to have forward references)
XmlContext xmlContext = (XmlContext) context;
Handle handle = xmlContext.createLocationHandle(attr);
handle.setClientData(attr);
if (mHandles == null) {
mHandles = new ArrayList<Pair<String,Handle>>();
}
mHandles.add(Pair.of(value, handle));
} else {
// Check siblings. TODO: Look for cycles!
if (ids.contains(value)) {
// Make sure it's not pointing to self
if (!ATTR_ID.equals(attr.getLocalName())
&& !selfId.isEmpty()
&& value.endsWith(selfId)
&& stripIdPrefix(value).equals(selfId)) {
XmlContext xmlContext = (XmlContext) context;
String message = String.format(
"Cannot be relative to self: id=%1$s, %2$s=%3$s",
selfId, attr.getLocalName(), selfId);
Location location = xmlContext.getLocation(attr);
xmlContext.report(NOT_SIBLING, attr, location, message);
}
continue;
}
if (value.startsWith(NEW_ID_PREFIX)) {
if (ids.contains(ID_PREFIX + stripIdPrefix(value))) {
continue;
}
} else {
assert value.startsWith(ID_PREFIX) : value;
if (ids.contains(NEW_ID_PREFIX + stripIdPrefix(value))) {
continue;
}
}
if (context.isEnabled(NOT_SIBLING)) {
XmlContext xmlContext = (XmlContext) context;
String message = String.format(
"`%1$s` is not a sibling in the same `RelativeLayout`",
value);
Location location = xmlContext.getLocation(attr);
xmlContext.report(NOT_SIBLING, attr, location, message);
}
}
}
}
}
}
}
mFileIds = null;
if (!context.getScope().contains(Scope.ALL_RESOURCE_FILES)) {
checkHandles(context);
}
}
@Override
public void afterCheckProject(@NonNull Context context) {
if (context.getScope().contains(Scope.ALL_RESOURCE_FILES)) {
checkHandles(context);
}
}
private void checkHandles(@NonNull Context context) {
if (mHandles != null) {
boolean checkSameLayout = context.isEnabled(UNKNOWN_ID_LAYOUT);
boolean checkExists = context.isEnabled(UNKNOWN_ID);
boolean projectScope = context.getScope().contains(Scope.ALL_RESOURCE_FILES);
for (Pair<String, Handle> pair : mHandles) {
String id = pair.getFirst();
boolean isBound = projectScope ? idDefined(mGlobalIds, id) :
idDefined(context, id, context.file);
LintClient client = context.getClient();
if (!isBound && checkExists
&& (projectScope || client.supportsProjectResources())) {
Handle handle = pair.getSecond();
boolean isDeclared = idDefined(mDeclaredIds, id);
id = stripIdPrefix(id);
String suggestionMessage;
Set<String> spellingDictionary = mGlobalIds;
if (!projectScope && client.supportsProjectResources()) {
AbstractResourceRepository resources =
client.getProjectResources(context.getProject(), true);
if (resources != null) {
spellingDictionary = Sets.newHashSet(
resources.getItemsOfType(ResourceType.ID));
spellingDictionary.remove(id);
}
}
List<String> suggestions = getSpellingSuggestions(id, spellingDictionary);
if (suggestions.size() > 1) {
suggestionMessage = String.format(" Did you mean one of {%2$s} ?",
id, Joiner.on(", ").join(suggestions));
} else if (!suggestions.isEmpty()) {
suggestionMessage = String.format(" Did you mean %2$s ?",
id, suggestions.get(0));
} else {
suggestionMessage = "";
}
String message;
if (isDeclared) {
message = String.format(
"The id \"`%1$s`\" is defined but not assigned to any views.%2$s",
id, suggestionMessage);
} else {
message = String.format(
"The id \"`%1$s`\" is not defined anywhere.%2$s",
id, suggestionMessage);
}
report(context, UNKNOWN_ID, handle, message);
} else if (checkSameLayout && (!projectScope || isBound)
&& id.startsWith(NEW_ID_PREFIX)) {
// The id was defined, but in a different layout. Usually not intentional
// (might be referring to a random other view that happens to have the same
// name.)
Handle handle = pair.getSecond();
report(context, UNKNOWN_ID_LAYOUT, handle,
String.format(
"The id \"`%1$s`\" is not referring to any views in this layout",
stripIdPrefix(id)));
}
}
}
}
private static void report(Context context, Issue issue, Handle handle, String message) {
Location location = handle.resolve();
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver().isSuppressed(null, issue, (Node) clientData)) {
return;
}
}
context.report(issue, location, message);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
if (element.getTagName().equals(RELATIVE_LAYOUT)) {
if (mRelativeLayouts == null) {
mRelativeLayouts = new ArrayList<Element>();
}
mRelativeLayouts.add(element);
} else {
assert element.getTagName().equals(TAG_ITEM);
String type = element.getAttribute(ATTR_TYPE);
if (VALUE_ID.equals(type)) {
String name = element.getAttribute(ATTR_NAME);
if (!name.isEmpty()) {
if (mDeclaredIds == null) {
mDeclaredIds = Sets.newHashSet();
}
mDeclaredIds.add(ID_PREFIX + name);
}
}
}
}
@Override
public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
assert attribute.getName().equals(ATTR_ID) || attribute.getLocalName().equals(ATTR_ID);
String id = attribute.getValue();
mFileIds.add(id);
mGlobalIds.add(id);
if (id.equals(NEW_ID_PREFIX) || id.equals(ID_PREFIX) || "@+id".equals(ID_PREFIX)) {
String message = "Invalid id: missing value";
context.report(INVALID, attribute, context.getLocation(attribute), message);
} else if (id.startsWith("@+") && !id.startsWith(NEW_ID_PREFIX) //$NON-NLS-1$
&& !id.startsWith("@+android:id/") //$NON-NLS-1$
|| id.startsWith(NEW_ID_PREFIX)
&& id.indexOf('/', NEW_ID_PREFIX.length()) != -1) {
int nameStart = id.startsWith(NEW_ID_PREFIX) ? NEW_ID_PREFIX.length() : 2;
String suggested = NEW_ID_PREFIX + id.substring(nameStart).replace('/', '_');
String message = String.format(
"ID definitions *must* be of the form `@+id/name`; try using `%1$s`", suggested);
context.report(INVALID, attribute, context.getLocation(attribute), message);
}
}
private static boolean idDefined(Set<String> ids, String id) {
if (ids == null) {
return false;
}
boolean definedLocally = ids.contains(id);
if (!definedLocally) {
if (id.startsWith(NEW_ID_PREFIX)) {
definedLocally = ids.contains(ID_PREFIX +
id.substring(NEW_ID_PREFIX.length()));
} else if (id.startsWith(ID_PREFIX)) {
definedLocally = ids.contains(NEW_ID_PREFIX +
id.substring(ID_PREFIX.length()));
}
}
return definedLocally;
}
private boolean idDefined(@NonNull Context context, @NonNull String id,
@Nullable File notIn) {
AbstractResourceRepository resources =
context.getClient().getProjectResources(context.getProject(), true);
if (resources != null) {
List<ResourceItem> items = resources.getResourceItem(ResourceType.ID,
stripIdPrefix(id));
if (items == null || items.isEmpty()) {
return false;
}
for (ResourceItem item : items) {
ResourceFile source = item.getSource();
if (source != null) {
File file = source.getFile();
if (file.getParentFile().getName().startsWith(FD_RES_VALUES)) {
if (mDeclaredIds == null) {
mDeclaredIds = Sets.newHashSet();
}
mDeclaredIds.add(id);
continue;
}
// Ignore definitions in the given file. This is used to ignore
// matches in the same file as the reference, since the reference
// is often expressed as a definition
if (!isSameResourceFile(file, notIn)) {
return true;
}
}
}
}
return false;
}
private static List<String> getSpellingSuggestions(String id, Collection<String> ids) {
int maxDistance = id.length() >= 4 ? 2 : 1;
// Look for typos and try to match with custom views and android views
Multimap<Integer, String> matches = ArrayListMultimap.create(2, 10);
int count = 0;
if (!ids.isEmpty()) {
for (String matchWith : ids) {
matchWith = stripIdPrefix(matchWith);
if (Math.abs(id.length() - matchWith.length()) > maxDistance) {
// The string lengths differ more than the allowed edit distance;
// no point in even attempting to compute the edit distance (requires
// O(n*m) storage and O(n*m) speed, where n and m are the string lengths)
continue;
}
int distance = editDistance(id, matchWith);
if (distance <= maxDistance) {
matches.put(distance, matchWith);
}
if (count++ > 100) {
// Make sure that for huge projects we don't completely grind to a halt
break;
}
}
}
for (int i = 0; i < maxDistance; i++) {
Collection<String> strings = matches.get(i);
if (strings != null && !strings.isEmpty()) {
List<String> suggestions = new ArrayList<String>(strings);
Collections.sort(suggestions);
return suggestions;
}
}
return Collections.emptyList();
}
}