1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tools.lint.checks; 18 19 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_RESOURCE_PREFIX; 20 import static com.android.tools.lint.detector.api.LintConstants.ATTR_NAME; 21 import static com.android.tools.lint.detector.api.LintConstants.ATTR_TRANSLATABLE; 22 import static com.android.tools.lint.detector.api.LintConstants.STRING_RESOURCE_PREFIX; 23 import static com.android.tools.lint.detector.api.LintConstants.TAG_ITEM; 24 import static com.android.tools.lint.detector.api.LintConstants.TAG_STRING; 25 import static com.android.tools.lint.detector.api.LintConstants.TAG_STRING_ARRAY; 26 27 import com.android.annotations.VisibleForTesting; 28 import com.android.resources.ResourceFolderType; 29 import com.android.tools.lint.detector.api.Category; 30 import com.android.tools.lint.detector.api.Context; 31 import com.android.tools.lint.detector.api.Issue; 32 import com.android.tools.lint.detector.api.Location; 33 import com.android.tools.lint.detector.api.ResourceXmlDetector; 34 import com.android.tools.lint.detector.api.Scope; 35 import com.android.tools.lint.detector.api.Severity; 36 import com.android.tools.lint.detector.api.Speed; 37 import com.android.tools.lint.detector.api.XmlContext; 38 import com.google.common.collect.Sets; 39 40 import org.w3c.dom.Attr; 41 import org.w3c.dom.Element; 42 import org.w3c.dom.Node; 43 import org.w3c.dom.NodeList; 44 45 import java.io.File; 46 import java.util.ArrayList; 47 import java.util.Arrays; 48 import java.util.Collection; 49 import java.util.Collections; 50 import java.util.HashMap; 51 import java.util.HashSet; 52 import java.util.List; 53 import java.util.Map; 54 import java.util.Set; 55 import java.util.regex.Pattern; 56 57 /** 58 * Checks for incomplete translations - e.g. keys that are only present in some 59 * locales but not all. 60 */ 61 public class TranslationDetector extends ResourceXmlDetector { 62 @VisibleForTesting 63 static boolean COMPLETE_REGIONS = 64 System.getenv("ANDROID_LINT_COMPLETE_REGIONS") != null; //$NON-NLS-1$ 65 66 private static final Pattern LANGUAGE_PATTERN = Pattern.compile("^[a-z]{2}$"); //$NON-NLS-1$ 67 private static final Pattern REGION_PATTERN = Pattern.compile("^r([A-Z]{2})$"); //$NON-NLS-1$ 68 69 /** Are all translations complete? */ 70 public static final Issue MISSING = Issue.create( 71 "MissingTranslation", //$NON-NLS-1$ 72 "Checks for incomplete translations where not all strings are translated", 73 "If an application has more than one locale, then all the strings declared in " + 74 "one language should also be translated in all other languages.\n" + 75 "\n" + 76 "By default this detector allows regions of a language to just provide a " + 77 "subset of the strings and fall back to the standard language strings. " + 78 "You can require all regions to provide a full translation by setting the " + 79 "environment variable ANDROID_LINT_COMPLETE_REGIONS.", 80 Category.MESSAGES, 81 8, 82 Severity.FATAL, 83 TranslationDetector.class, 84 Scope.ALL_RESOURCES_SCOPE); 85 86 /** Are there extra translations that are "unused" (appear only in specific languages) ? */ 87 public static final Issue EXTRA = Issue.create( 88 "ExtraTranslation", //$NON-NLS-1$ 89 "Checks for translations that appear to be unused (no default language string)", 90 "If a string appears in a specific language translation file, but there is " + 91 "no corresponding string in the default locale, then this string is probably " + 92 "unused. (It's technically possible that your application is only intended to " + 93 "run in a specific locale, but it's still a good idea to provide a fallback.)", 94 Category.MESSAGES, 95 6, 96 Severity.WARNING, 97 TranslationDetector.class, 98 Scope.ALL_RESOURCES_SCOPE); 99 100 private Set<String> mNames; 101 private Set<String> mTranslatedArrays; 102 private boolean mIgnoreFile; 103 private Map<File, Set<String>> mFileToNames; 104 105 /** Locations for each untranslated string name. Populated during phase 2, if necessary */ 106 private Map<String, Location> mMissingLocations; 107 108 /** Locations for each extra translated string name. Populated during phase 2, if necessary */ 109 private Map<String, Location> mExtraLocations; 110 111 /** Error messages for each untranslated string name. Populated during phase 2, if necessary */ 112 private Map<String, String> mDescriptions; 113 114 /** Constructs a new {@link TranslationDetector} */ TranslationDetector()115 public TranslationDetector() { 116 } 117 118 @Override appliesTo(ResourceFolderType folderType)119 public boolean appliesTo(ResourceFolderType folderType) { 120 return folderType == ResourceFolderType.VALUES; 121 } 122 123 @Override getSpeed()124 public Speed getSpeed() { 125 return Speed.NORMAL; 126 } 127 128 @Override getApplicableElements()129 public Collection<String> getApplicableElements() { 130 return Arrays.asList( 131 TAG_STRING, 132 TAG_STRING_ARRAY 133 ); 134 } 135 136 @Override beforeCheckProject(Context context)137 public void beforeCheckProject(Context context) { 138 if (context.getDriver().getPhase() == 1) { 139 mFileToNames = new HashMap<File, Set<String>>(); 140 } 141 } 142 143 @Override beforeCheckFile(Context context)144 public void beforeCheckFile(Context context) { 145 if (context.getPhase() == 1) { 146 mNames = new HashSet<String>(); 147 } 148 149 // Convention seen in various projects 150 mIgnoreFile = context.file.getName().startsWith("donottranslate"); //$NON-NLS-1$ 151 } 152 153 @Override afterCheckFile(Context context)154 public void afterCheckFile(Context context) { 155 if (context.getPhase() == 1) { 156 // Store this layout's set of ids for full project analysis in afterCheckProject 157 mFileToNames.put(context.file, mNames); 158 159 mNames = null; 160 } 161 } 162 163 @Override afterCheckProject(Context context)164 public void afterCheckProject(Context context) { 165 if (context.getPhase() == 1) { 166 // NOTE - this will look for the presence of translation strings. 167 // If you create a resource folder but don't actually place a file in it 168 // we won't detect that, but it seems like a smaller problem. 169 170 checkTranslations(context); 171 172 mFileToNames = null; 173 174 if (mMissingLocations != null || mExtraLocations != null) { 175 context.getDriver().requestRepeat(this, Scope.ALL_RESOURCES_SCOPE); 176 } 177 } else { 178 assert context.getPhase() == 2; 179 180 reportMap(context, MISSING, mMissingLocations); 181 reportMap(context, EXTRA, mExtraLocations); 182 mMissingLocations = null; 183 mExtraLocations = null; 184 mDescriptions = null; 185 } 186 } 187 reportMap(Context context, Issue issue, Map<String, Location> map)188 private void reportMap(Context context, Issue issue, Map<String, Location> map) { 189 if (map != null) { 190 for (Map.Entry<String, Location> entry : map.entrySet()) { 191 Location location = entry.getValue(); 192 String name = entry.getKey(); 193 String message = mDescriptions.get(name); 194 195 // We were prepending locations, but we want to prefer the base folders 196 location = Location.reverse(location); 197 198 context.report(issue, location, message, null); 199 } 200 } 201 } 202 checkTranslations(Context context)203 private void checkTranslations(Context context) { 204 // Only one file defining strings? If so, no problems. 205 Set<File> files = mFileToNames.keySet(); 206 if (files.size() == 1) { 207 return; 208 } 209 210 Set<File> parentFolders = new HashSet<File>(); 211 for (File file : files) { 212 parentFolders.add(file.getParentFile()); 213 } 214 if (parentFolders.size() == 1) { 215 // Only one language - no problems. 216 return; 217 } 218 219 boolean reportMissing = context.isEnabled(MISSING); 220 boolean reportExtra = context.isEnabled(EXTRA); 221 222 // res/strings.xml etc 223 String defaultLanguage = "Default"; 224 225 Map<File, String> parentFolderToLanguage = new HashMap<File, String>(); 226 for (File parent : parentFolders) { 227 String name = parent.getName(); 228 229 // Look up the language for this folder. 230 String language = getLanguage(name); 231 if (language == null) { 232 language = defaultLanguage; 233 } 234 235 parentFolderToLanguage.put(parent, language); 236 } 237 238 int languageCount = parentFolderToLanguage.values().size(); 239 if (languageCount <= 1) { 240 // At most one language -- no problems. 241 return; 242 } 243 244 // Merge together the various files building up the translations for each language 245 Map<String, Set<String>> languageToStrings = 246 new HashMap<String, Set<String>>(languageCount); 247 Set<String> allStrings = new HashSet<String>(200); 248 for (File file : files) { 249 String language = parentFolderToLanguage.get(file.getParentFile()); 250 assert language != null : file.getParent(); 251 Set<String> fileStrings = mFileToNames.get(file); 252 253 Set<String> languageStrings = languageToStrings.get(language); 254 if (languageStrings == null) { 255 // We don't need a copy; we're done with the string tables now so we 256 // can modify them 257 languageToStrings.put(language, fileStrings); 258 } else { 259 languageStrings.addAll(fileStrings); 260 } 261 allStrings.addAll(fileStrings); 262 } 263 264 Set<String> defaultStrings = languageToStrings.get(defaultLanguage); 265 if (defaultStrings == null) { 266 defaultStrings = new HashSet<String>(); 267 } 268 269 // Fast check to see if there's no problem: if the default locale set is the 270 // same as the all set (meaning there are no extra strings in the other languages) 271 // then we can quickly determine if everything is okay by just making sure that 272 // each language defines everything. If that's the case they will all have the same 273 // string count. 274 int stringCount = allStrings.size(); 275 if (stringCount == defaultStrings.size()) { 276 boolean haveError = false; 277 for (Map.Entry<String, Set<String>> entry : languageToStrings.entrySet()) { 278 Set<String> strings = entry.getValue(); 279 if (stringCount != strings.size()) { 280 haveError = true; 281 break; 282 } 283 } 284 if (!haveError) { 285 return; 286 } 287 } 288 289 // Do we need to resolve fallback strings for regions that only define a subset 290 // of the strings in the language and fall back on the main language for the rest? 291 if (!COMPLETE_REGIONS) { 292 for (String l : languageToStrings.keySet()) { 293 if (l.indexOf('-') != -1) { 294 // Yes, we have regions. Merge all base language string names into each region. 295 for (Map.Entry<String, Set<String>> entry : languageToStrings.entrySet()) { 296 Set<String> strings = entry.getValue(); 297 if (stringCount != strings.size()) { 298 String languageRegion = entry.getKey(); 299 int regionIndex = languageRegion.indexOf('-'); 300 if (regionIndex != -1) { 301 String language = languageRegion.substring(0, regionIndex); 302 Set<String> fallback = languageToStrings.get(language); 303 if (fallback != null) { 304 strings.addAll(fallback); 305 } 306 } 307 } 308 } 309 // We only need to do this once; when we see the first region we know 310 // we need to do it; once merged we can bail 311 break; 312 } 313 } 314 } 315 316 List<String> languages = new ArrayList<String>(languageToStrings.keySet()); 317 Collections.sort(languages); 318 for (String language : languages) { 319 Set<String> strings = languageToStrings.get(language); 320 if (defaultLanguage.equals(language)) { 321 continue; 322 } 323 324 // if strings.size() == stringCount, then this language is defining everything, 325 // both all the default language strings and the union of all extra strings 326 // defined in other languages, so there's no problem. 327 if (stringCount != strings.size()) { 328 if (reportMissing) { 329 Set<String> difference = Sets.difference(defaultStrings, strings); 330 if (difference.size() > 0) { 331 if (mMissingLocations == null) { 332 mMissingLocations = new HashMap<String, Location>(); 333 } 334 if (mDescriptions == null) { 335 mDescriptions = new HashMap<String, String>(); 336 } 337 338 for (String s : difference) { 339 mMissingLocations.put(s, null); 340 String message = mDescriptions.get(s); 341 if (message == null) { 342 message = String.format("\"%1$s\" is not translated in %2$s", 343 s, language); 344 } else { 345 message = message + ", " + language; 346 } 347 mDescriptions.put(s, message); 348 } 349 } 350 } 351 352 if (reportExtra) { 353 Set<String> difference = Sets.difference(strings, defaultStrings); 354 if (difference.size() > 0) { 355 if (mExtraLocations == null) { 356 mExtraLocations = new HashMap<String, Location>(); 357 } 358 if (mDescriptions == null) { 359 mDescriptions = new HashMap<String, String>(); 360 } 361 362 for (String s : difference) { 363 if (mTranslatedArrays != null && mTranslatedArrays.contains(s)) { 364 continue; 365 } 366 mExtraLocations.put(s, null); 367 String message = String.format( 368 "\"%1$s\" is translated here but not found in default locale", s); 369 mDescriptions.put(s, message); 370 } 371 } 372 } 373 } 374 } 375 } 376 377 /** Look up the language for the given folder name */ getLanguage(String name)378 private static String getLanguage(String name) { 379 String[] segments = name.split("-"); //$NON-NLS-1$ 380 381 // TODO: To get an accurate answer, this should later do a 382 // FolderConfiguration.getConfig(String[] folderSegments) 383 // to obtain a FolderConfiguration, then call 384 // getLanguageQualifier() on it, and if not null, call getValue() to get the 385 // actual language value. 386 // However, we don't have ide_common on the build path for lint, so for now 387 // use a simple guess about what constitutes a language qualifier here: 388 389 String language = null; 390 for (String segment : segments) { 391 // Language 392 if (language == null && segment.length() == 2 393 && LANGUAGE_PATTERN.matcher(segment).matches()) { 394 language = segment; 395 } 396 397 // Add in region 398 if (language != null && segment.length() == 3 399 && REGION_PATTERN.matcher(segment).matches()) { 400 language = language + '-' + segment; 401 break; 402 } 403 } 404 405 return language; 406 } 407 408 @Override visitElement(XmlContext context, Element element)409 public void visitElement(XmlContext context, Element element) { 410 if (mIgnoreFile) { 411 return; 412 } 413 414 Attr attribute = element.getAttributeNode(ATTR_NAME); 415 416 if (context.getPhase() == 2) { 417 // Just locating names requested in the {@link #mLocations} map 418 if (attribute == null) { 419 return; 420 } 421 String name = attribute.getValue(); 422 if (mMissingLocations != null && mMissingLocations.containsKey(name)) { 423 String language = getLanguage(context.file.getParentFile().getName()); 424 if (language == null) { 425 if (context.getDriver().isSuppressed(MISSING, element)) { 426 mMissingLocations.remove(name); 427 return; 428 } 429 430 Location location = context.getLocation(element); 431 location.setClientData(element); 432 location.setSecondary(mMissingLocations.get(name)); 433 mMissingLocations.put(name, location); 434 } 435 } 436 if (mExtraLocations != null && mExtraLocations.containsKey(name)) { 437 if (context.getDriver().isSuppressed(EXTRA, element)) { 438 mExtraLocations.remove(name); 439 return; 440 } 441 Location location = context.getLocation(element); 442 location.setClientData(element); 443 location.setMessage("Also translated here"); 444 location.setSecondary(mExtraLocations.get(name)); 445 mExtraLocations.put(name, location); 446 } 447 return; 448 } 449 450 assert context.getPhase() == 1; 451 if (attribute == null || attribute.getValue().length() == 0) { 452 context.report(MISSING, element, context.getLocation(element), 453 "Missing name attribute in <string> declaration", null); 454 } else { 455 String name = attribute.getValue(); 456 457 Attr translatable = element.getAttributeNode(ATTR_TRANSLATABLE); 458 if (translatable != null && !Boolean.valueOf(translatable.getValue())) { 459 return; 460 } 461 462 if (element.getTagName().equals(TAG_STRING_ARRAY) && 463 allItemsAreReferences(element)) { 464 // No need to provide translations for string arrays where all 465 // the children items are defined as translated string resources, 466 // e.g. 467 // <string-array name="foo"> 468 // <item>@string/item1</item> 469 // <item>@string/item2</item> 470 // </string-array> 471 // However, we need to remember these names such that we don't consider 472 // these arrays "extra" if one of the *translated* versions of the array 473 // perform an inline translation of an array item 474 if (mTranslatedArrays == null) { 475 mTranslatedArrays = new HashSet<String>(); 476 } 477 mTranslatedArrays.add(name); 478 return; 479 } 480 481 // Check for duplicate name definitions? No, because there can be 482 // additional customizations like product= 483 //if (mNames.contains(name)) { 484 // context.mClient.report(ISSUE, context.getLocation(attribute), 485 // String.format("Duplicate name %1$s, already defined earlier in this file", 486 // name)); 487 //} 488 489 mNames.add(name); 490 491 // TBD: Also make sure that the strings are not empty or placeholders? 492 } 493 } 494 allItemsAreReferences(Element element)495 private boolean allItemsAreReferences(Element element) { 496 assert element.getTagName().equals(TAG_STRING_ARRAY); 497 NodeList childNodes = element.getChildNodes(); 498 for (int i = 0, n = childNodes.getLength(); i < n; i++) { 499 Node item = childNodes.item(i); 500 if (item.getNodeType() == Node.ELEMENT_NODE && 501 TAG_ITEM.equals(item.getNodeName())) { 502 NodeList itemChildren = item.getChildNodes(); 503 for (int j = 0, m = itemChildren.getLength(); j < m; j++) { 504 Node valueNode = itemChildren.item(j); 505 if (valueNode.getNodeType() == Node.TEXT_NODE) { 506 String value = valueNode.getNodeValue().trim(); 507 if (!value.startsWith(ANDROID_RESOURCE_PREFIX) 508 && !value.startsWith(STRING_RESOURCE_PREFIX)) { 509 return false; 510 } 511 } 512 } 513 } 514 } 515 516 return true; 517 } 518 } 519