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