blob: b36e0ef224db2d548fb122c81cc8b2c71e18b5a9 [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.ATTR_NAME;
import static com.android.SdkConstants.TAG_ARRAY;
import static com.android.SdkConstants.TAG_INTEGER_ARRAY;
import static com.android.SdkConstants.TAG_STRING_ARRAY;
import com.android.annotations.NonNull;
import com.android.ide.common.rendering.api.ArrayResourceValue;
import com.android.ide.common.rendering.api.ResourceNamespace;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.ide.common.resources.ResourceItem;
import com.android.ide.common.resources.ResourceRepository;
import com.android.ide.common.util.PathString;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.client.api.LintDriver;
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.Lint;
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.XmlContext;
import com.android.utils.Pair;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
/** Checks for arrays with inconsistent item counts */
public class ArraySizeDetector extends ResourceXmlDetector {
/** Are there differences in how many array elements are declared? */
public static final Issue INCONSISTENT =
Issue.create(
"InconsistentArrays",
"Inconsistencies in array element counts",
"When an array is translated in a different locale, it should normally have "
+ "the same number of elements as the original array. When adding or removing "
+ "elements to an array, it is easy to forget to update all the locales, and this "
+ "lint warning finds inconsistencies like these.\n"
+ "\n"
+ "Note however that there may be cases where you really want to declare a "
+ "different number of array items in each configuration (for example where "
+ "the array represents available options, and those options differ for "
+ "different layout orientations and so on), so use your own judgement to "
+ "decide if this is really an error.\n"
+ "\n"
+ "You can suppress this error type if it finds false errors in your project.",
Category.CORRECTNESS,
7,
Severity.WARNING,
new Implementation(ArraySizeDetector.class, Scope.RESOURCE_FILE_SCOPE));
private Multimap<File, Pair<String, Integer>> mFileToArrayCount;
/** Locations for each array name. Populated during phase 2, if necessary */
private Map<String, Location> mLocations;
/** Error messages for each array name. Populated during phase 2, if necessary */
private Map<String, String> mDescriptions;
/** Constructs a new {@link ArraySizeDetector} */
public ArraySizeDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.VALUES;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(TAG_ARRAY, TAG_STRING_ARRAY, TAG_INTEGER_ARRAY);
}
@Override
public void beforeCheckRootProject(@NonNull Context context) {
if (context.getPhase() == 1) {
mFileToArrayCount = ArrayListMultimap.create(30, 5);
}
}
@Override
public void afterCheckRootProject(@NonNull Context context) {
if (context.getPhase() == 1) {
boolean haveAllResources = context.getScope().contains(Scope.ALL_RESOURCE_FILES);
if (!haveAllResources) {
return;
}
// Check that all arrays for the same name have the same number of translations
LintClient client = context.getClient();
Set<String> alreadyReported = new HashSet<>();
Map<String, Integer> countMap = new HashMap<>();
Map<String, File> fileMap = new HashMap<>();
// Process the file in sorted file order to ensure stable output
List<File> keys = new ArrayList<>(mFileToArrayCount.keySet());
Collections.sort(keys);
for (File file : keys) {
Collection<Pair<String, Integer>> pairs = mFileToArrayCount.get(file);
for (Pair<String, Integer> pair : pairs) {
String name = pair.getFirst();
if (alreadyReported.contains(name)) {
continue;
}
Integer count = pair.getSecond();
Integer current = countMap.get(name);
if (current == null) {
countMap.put(name, count);
fileMap.put(name, file);
} else if (!count.equals(current)) {
alreadyReported.add(name);
if (mLocations == null) {
mLocations = new HashMap<>();
mDescriptions = new HashMap<>();
}
mLocations.put(name, null);
String thisName = Lint.getFileNameWithParent(client, file);
File otherFile = fileMap.get(name);
String otherName = Lint.getFileNameWithParent(client, otherFile);
String message =
String.format(
"Array `%1$s` has an inconsistent number of items (%2$d in `%3$s`, %4$d in `%5$s`)",
name, count, thisName, current, otherName);
mDescriptions.put(name, message);
}
}
}
//noinspection VariableNotUsedInsideIf
if (mLocations != null) {
// Request another scan through the resources such that we can
// gather the actual locations
context.getDriver().requestRepeat(this, Scope.ALL_RESOURCES_SCOPE);
}
mFileToArrayCount = null;
} else {
if (mLocations != null) {
List<String> names = new ArrayList<>(mLocations.keySet());
Collections.sort(names);
for (String name : names) {
Location location = mLocations.get(name);
if (location == null) {
// Suppressed; see visitElement
continue;
}
// We were prepending locations, but we want to prefer the base folders
location = Location.reverse(location);
// Make sure we still have a conflict, in case one or more of the
// elements were marked with tools:ignore
int count = -1;
LintDriver driver = context.getDriver();
boolean foundConflict = false;
Location curr;
for (curr = location; curr != null; curr = curr.getSecondary()) {
Object clientData = curr.getClientData();
if (clientData instanceof Node) {
Node node = (Node) clientData;
if (driver.isSuppressed(null, INCONSISTENT, node)) {
continue;
}
int newCount = Lint.getChildCount(node);
if (newCount != count) {
if (count == -1) {
count = newCount; // first number encountered
} else {
foundConflict = true;
break;
}
}
} else {
foundConflict = true;
break;
}
}
// Through one or more tools:ignore, there is no more conflict so
// ignore this element
if (!foundConflict) {
continue;
}
String message = mDescriptions.get(name);
context.report(INCONSISTENT, location, message);
}
}
mLocations = null;
mDescriptions = null;
}
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
int phase = context.getPhase();
Attr attribute = element.getAttributeNode(ATTR_NAME);
if (attribute == null || attribute.getValue().isEmpty()) {
if (phase != 1) {
return;
}
context.report(
INCONSISTENT,
element,
context.getLocation(element),
String.format(
"Missing name attribute in `%1$s` declaration", element.getTagName()));
} else {
String name = attribute.getValue();
if (phase == 1) {
if (context.getProject().getReportIssues()) {
int childCount = Lint.getChildCount(element);
if (!context.getScope().contains(Scope.ALL_RESOURCE_FILES)
&& context.getClient().supportsProjectResources()) {
incrementalCheckCount(context, element, name, childCount);
return;
}
mFileToArrayCount.put(context.file, Pair.of(name, childCount));
}
} else {
assert phase == 2;
if (mLocations.containsKey(name)) {
if (context.getDriver().isSuppressed(context, INCONSISTENT, element)) {
return;
}
Location location = context.getLocation(element);
location.setData(element);
location.setMessage(
String.format(
"Declaration with array size (%1$d)",
Lint.getChildCount(element)));
location.setSecondary(mLocations.get(name));
mLocations.put(name, location);
}
}
}
}
private static void incrementalCheckCount(
@NonNull XmlContext context,
@NonNull Element element,
@NonNull String name,
int childCount) {
LintClient client = context.getClient();
Project project = context.getMainProject();
ResourceRepository resources = client.getResourceRepository(project, true, false);
if (resources == null) {
return;
}
List<ResourceItem> items =
resources.getResources(ResourceNamespace.TODO(), ResourceType.ARRAY, name);
for (ResourceItem item : items) {
PathString source = item.getSource();
if (source != null && Lint.isSameResourceFile(context.file, source.toFile())) {
continue;
}
ResourceValue rv = item.getResourceValue();
if (rv instanceof ArrayResourceValue) {
ArrayResourceValue arv = (ArrayResourceValue) rv;
if (childCount != arv.getElementCount()) {
String thisName = Lint.getFileNameWithParent(client, context.file);
assert source != null;
String otherName = Lint.getFileNameWithParent(client, source);
String message =
String.format(
"Array `%1$s` has an inconsistent number of items (%2$d in `%3$s`, %4$d in `%5$s`)",
name, childCount, thisName, arv.getElementCount(), otherName);
context.report(INCONSISTENT, element, context.getLocation(element), message);
}
}
}
}
}