• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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