/* * 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.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.stripIdPrefix; import com.android.annotations.NonNull; import com.android.resources.ResourceFolderType; import com.android.tools.lint.client.api.IDomParser; import com.android.tools.lint.detector.api.Category; import com.android.tools.lint.detector.api.Context; import com.android.tools.lint.detector.api.Issue; import com.android.tools.lint.detector.api.LayoutDetector; import com.android.tools.lint.detector.api.LintUtils; 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 org.w3c.dom.NodeList; 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 { /** Ids bound to widgets in any of the layout files */ private 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 */ public static final Issue UNKNOWN_ID = Issue.create( "UnknownId", //$NON-NLS-1$ "Checks for id references in RelativeLayouts that are not defined elsewhere", "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, WrongIdDetector.class, Scope.ALL_RESOURCES_SCOPE); /** Reference to an id that is not in the current layout */ public static final Issue UNKNOWN_ID_LAYOUT = Issue.create( "UnknownIdInLayout", //$NON-NLS-1$ "Makes sure that @+id references refer to views in the same 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, 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; } @Override public @NonNull 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) { NodeList children = layout.getChildNodes(); for (int j = 0, childCount = children.getLength(); j < childCount; j++) { Node child = children.item(j); if (child.getNodeType() != Node.ELEMENT_NODE) { continue; } Element element = (Element) child; 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; IDomParser parser = xmlContext.parser; Handle handle = parser.createLocationHandle(xmlContext, attr); handle.setClientData(attr); if (mHandles == null) { mHandles = new ArrayList<Pair<String,Handle>>(); } mHandles.add(Pair.of(value, handle)); } } } } } } mFileIds = null; } @Override public void afterCheckProject(@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 = idDefined(mGlobalIds, id); if (!isBound && checkExists && projectScope) { Handle handle = pair.getSecond(); boolean isDeclared = idDefined(mDeclaredIds, id); id = stripIdPrefix(id); String suggestionMessage; List<String> suggestions = getSpellingSuggestions(id, mGlobalIds); if (suggestions.size() > 1) { suggestionMessage = String.format(" Did you mean one of {%2$s} ?", id, Joiner.on(", ").join(suggestions)); } else if (suggestions.size() > 0) { 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 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(issue, (Node) clientData)) { return; } } context.report(issue, location, message, null); } @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.length() > 0) { 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); } 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 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.size() > 0) { 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 = LintUtils.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> s = matches.get(i); if (s != null && s.size() > 0) { List<String> suggestions = new ArrayList<String>(s); Collections.sort(suggestions); return suggestions; } } return Collections.emptyList(); } }