/* * Copyright (C) 2014 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.tools.lint.checks.PluralsDatabase.FLAG_FEW; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MANY; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_ONE; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_TWO; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_MULTIPLE_ZERO; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_ONE; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_TWO; import static com.android.tools.lint.checks.PluralsDatabase.FLAG_ZERO; import static com.android.tools.lint.checks.PluralsDatabase.Quantity; import static com.android.tools.lint.checks.PluralsDatabase.Quantity.few; import static com.android.tools.lint.checks.PluralsDatabase.Quantity.many; import static com.android.tools.lint.checks.PluralsDatabase.Quantity.one; import static com.android.tools.lint.checks.PluralsDatabase.Quantity.two; import static com.android.tools.lint.checks.PluralsDatabase.Quantity.zero; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.ide.common.resources.LocaleManager; import com.google.common.base.Charsets; import com.google.common.base.Objects; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.io.ByteStreams; import junit.framework.TestCase; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public class PluralsDatabaseTest extends TestCase { public void testGetRelevant() { PluralsDatabase db = PluralsDatabase.get(); assertNull(db.getRelevant("unknown")); EnumSet<Quantity> relevant = db.getRelevant("en"); assertNotNull(relevant); assertEquals(1, relevant.size()); assertSame(Quantity.one, relevant.iterator().next()); relevant = db.getRelevant("cs"); assertNotNull(relevant); assertEquals(EnumSet.of(Quantity.few, Quantity.one), relevant); } public void testFindExamples() { PluralsDatabase db = PluralsDatabase.get(); //noinspection ConstantConditions assertEquals("1, 101, 201, 301, 401, 501, 601, 701, 1001, \u2026", db.findIntegerExamples("sl", Quantity.one)); assertEquals("1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, \u2026", db.findIntegerExamples("ru", Quantity.one)); } public void testHasMultiValue() { PluralsDatabase db = PluralsDatabase.get(); assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.one)); assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.two)); assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.few)); assertFalse(db.hasMultipleValuesForQuantity("en", Quantity.many)); assertTrue(db.hasMultipleValuesForQuantity("br", Quantity.two)); assertTrue(db.hasMultipleValuesForQuantity("mk", Quantity.one)); assertTrue(db.hasMultipleValuesForQuantity("lv", Quantity.zero)); } /** * If the lint unit test data/ folder contains a plurals.txt database file, * this test will parse that file and ensure that our current database produces * exactly the same results as those inferred from the file. If not, it will * dump out updated data structures for the database. */ public void testDatabaseAccurate() { List<String> languages = new ArrayList<String>(LocaleManager.getLanguageCodes()); Collections.sort(languages); PluralsTextDatabase db = PluralsTextDatabase.get(); db.ensureInitialized(); if (db.getSetName("en") == null) { // plurals.txt not found System.out.println("No plurals.txt database included; not checking consistency"); return; } // Ensure that the two databases (the plurals.txt backed one and our actual // database) fully agree on everything PluralsDatabase pdb = PluralsDatabase.get(); for (String language : languages) { if (!Objects.equal(pdb.getRelevant(language), db.getRelevant(language))) { dumpDatabaseTables(); assertEquals(language, pdb.getRelevant(language), db.getRelevant(language)); } if (db.getSetName(language) == null) { continue; } for (Quantity q : Quantity.values()) { boolean mv1 = pdb.hasMultipleValuesForQuantity(language, q); boolean mv2 = db.hasMultipleValuesForQuantity(language, q); if (mv1 != mv2) { dumpDatabaseTables(); assertEquals(language, mv1, mv2); } if (mv2) { String e1 = pdb.findIntegerExamples(language, q); String e2 = db.findIntegerExamples(language, q); if (!Objects.equal(e1, e2)) { dumpDatabaseTables(); assertEquals(language, e1, e2); } } } } } private static void dumpDatabaseTables() { List<String> languages = new ArrayList<String>(LocaleManager.getLanguageCodes()); Collections.sort(languages); PluralsTextDatabase db = PluralsTextDatabase.get(); db.ensureInitialized(); db.getRelevant("en"); // ensure initialized Map<String,String> languageMap = Maps.newHashMap(); Map<String,EnumSet<Quantity>> setMap = Maps.newHashMap(); for (String language : languages) { String set = db.getSetName(language); if (set == null) { continue; } EnumSet<Quantity> quantitySet = db.getRelevant(language); if (quantitySet == null) { // No plurals data for this language. For example, in ICU 52, no // plurals data for the "nv" language (Navajo). continue; } assertNotNull(language, quantitySet); setMap.put(set, quantitySet); languageMap.put(set, language); // Could be multiple } List<String> setNames = Lists.newArrayList(setMap.keySet()); Collections.sort(setNames); // Compute uniqueness Map<String,String> sameAs = Maps.newHashMap(); for (int i = 0, n = setNames.size(); i < n; i++) { for (int j = i + 1; j < n; j++) { String iSetName = setNames.get(i); String jSetName = setNames.get(j); assertNotNull(iSetName); assertNotNull(jSetName); EnumSet<Quantity> iSet = setMap.get(iSetName); EnumSet<Quantity> jSet = setMap.get(jSetName); assertNotNull(iSet); assertNotNull(jSet); if (iSet.equals(jSet)) { String alias = sameAs.get(iSetName); if (alias != null) { iSetName = alias; } sameAs.put(jSetName, iSetName); break; } } } final String indent = " "; StringBuilder sb = new StringBuilder(); // Multi Value Set names Set<String> sets = Sets.newHashSet(); for (String language : languages) { String set = db.getSetName(language); sets.add(set); languageMap.put(set, language); // Could be multiple } Map<String,Integer> indices = Maps.newTreeMap(); int index = 0; for (String set : setNames) { indices.put(set, index++); } // Language indices Map<String,Integer> languageIndices = Maps.newTreeMap(); index = 0; for (String language : languages) { String set = db.getSetName(language); if (set == null) { continue; } languageIndices.put(language, index++); } Map<String, String> zero = computeExamples(db, Quantity.zero, sets, languageMap); Map<String, String> one = computeExamples(db, Quantity.one, sets, languageMap); Map<String, String> two = computeExamples(db, Quantity.two, sets, languageMap); // Language map sb.setLength(0); sb.append("/** Set of language codes relevant to plurals data */\n"); sb.append("private static final String[] LANGUAGE_CODES = new String[] {\n"); int column = 0; index = 0; sb.append(indent); for (String language : languages) { String set = db.getSetName(language); if (set == null) { continue; } sb.append('"').append(language).append("\", "); column++; if (column == 10) { sb.append("\n"); sb.append(indent); column = 0; } assertEquals((int)languageIndices.get(language), index); index++; } sb.append("\n};\n"); System.out.println(sb); // Quantity map sb.setLength(0); sb.append("/**\n" + " * Relevant flags for each language (corresponding to each language listed\n" + " * in the same position in {@link #LANGUAGE_CODES})\n" + " */\n"); sb.append("private static final int[] FLAGS = new int[] {\n"); column = 0; sb.append(indent); index = 0; for (String language : languages) { String setName = db.getSetName(language); if (setName == null) { continue; } assertEquals((int)languageIndices.get(language), index); // Compute flag int flag = 0; EnumSet<Quantity> relevant = db.getRelevant(language); assertNotNull(relevant); if (relevant.contains(Quantity.zero)) { flag |= FLAG_ZERO; } if (relevant.contains(Quantity.one)) { flag |= FLAG_ONE; } if (relevant.contains(Quantity.two)) { flag |= FLAG_TWO; } if (relevant.contains(Quantity.few)) { flag |= FLAG_FEW; } if (relevant.contains(Quantity.many)) { flag |= FLAG_MANY; } if (zero.containsKey(setName)) { flag |= FLAG_MULTIPLE_ZERO; } if (one.containsKey(setName)) { flag |= FLAG_MULTIPLE_ONE; } if (two.containsKey(setName)) { flag |= FLAG_MULTIPLE_TWO; } sb.append(String.format(Locale.US, "0x%04x, ", flag)); column++; if (column == 8) { sb.append("\n"); sb.append(indent); column = 0; } index++; } sb.append("\n};\n"); System.out.println(sb); // Switch statement methods for examples printSwitch(db, Quantity.zero, languages, languageIndices, indices, zero); printSwitch(db, Quantity.one, languages, languageIndices, indices, one); printSwitch(db, Quantity.two, languages, languageIndices, indices, two); } private static Map<String, String> computeExamples(PluralsTextDatabase db, Quantity quantity, Set<String> sets, Map<String, String> languageMap) { Map<String, String> setsWithExamples = Maps.newHashMap(); for (String set : sets) { String language = languageMap.get(set); String examples = db.findIntegerExamples(language, quantity); if (examples != null && examples.indexOf(',') != -1) { setsWithExamples.put(set, examples); } } return setsWithExamples; } private static void printSwitch( PluralsTextDatabase db, Quantity quantity, List<String> languages, Map<String,Integer> languageIndices, Map<String, Integer> indices, Map<String, String> setsWithExamples) { List<String> sorted = new ArrayList<String>(setsWithExamples.keySet()); Collections.sort(sorted); StringBuilder sb = new StringBuilder(); String quantityName = quantity.name(); quantityName = Character.toUpperCase(quantityName.charAt(0)) + quantityName.substring(1); sb.append(" @Nullable\n" + " private static String getExampleForQuantity").append(quantityName) .append("(@NonNull String language) {\n" + " int index = getLanguageIndex(language);\n" + " switch (index) {\n"); for (Map.Entry<String, Integer> entry : indices.entrySet()) { String set = entry.getKey(); if (!setsWithExamples.containsKey(set)) { continue; } String example = setsWithExamples.get(set); example = example.replace("…", "\\u2026"); sb.append(" // ").append(set).append("\n"); for (String language : languages) { String setName = db.getSetName(language); if (set.equals(setName)) { int languageIndex = languageIndices.get(language); sb.append(" case "); sb.append(languageIndex).append(": // ").append(language).append("\n"); } } sb.append(" return "); sb.append("\"").append(example).append("\""); sb.append(";\n"); } sb.append(" case -1:\n" + " default:\n" + " return null;\n" + " }\n" + " }\n"); System.out.println(sb); } /** * Plurals database backed by a plurals.txt file from ICU */ private static class PluralsTextDatabase { private static final boolean DEBUG = false; private static final EnumSet<Quantity> NONE = EnumSet.noneOf(Quantity.class); private static final PluralsTextDatabase sInstance = new PluralsTextDatabase(); private Map<String, EnumSet<Quantity>> mPlurals; private Map<Quantity, Set<String>> mMultiValueSetNames = Maps.newEnumMap(Quantity.class); private String mDescriptions; private int mRuleSetOffset; private Map<String,String> mSetNamePerLanguage; @NonNull public static PluralsTextDatabase get() { return sInstance; } @Nullable public EnumSet<Quantity> getRelevant(@NonNull String language) { ensureInitialized(); EnumSet<Quantity> set = mPlurals.get(language); if (set == null) { String s = getLocaleData(language); if (s == null) { mPlurals.put(language, NONE); return null; } // Process each item and look for relevance set = EnumSet.noneOf(Quantity.class); int length = s.length(); for (int offset = 0, end; offset < length; offset = end + 1) { for (; offset < length; offset++) { if (!Character.isWhitespace(s.charAt(offset))) { break; } } int begin = s.indexOf('{', offset); if (begin == -1) { break; } end = findBalancedEnd(s, begin); if (end == -1) { end = length; } if (s.startsWith("other{", offset)) { // Not included continue; } // Make sure the rule references applies to integers: // Rule definition mentions n or i or @integer // // n absolute value of the source number (integer and decimals). // i integer digits of n. // v number of visible fraction digits in n, with trailing zeros. // w number of visible fraction digits in n, without trailing zeros. // f visible fractional digits in n, with trailing zeros. // t visible fractional digits in n, without trailing zeros. boolean appliesToIntegers = false; boolean inQuotes = false; for (int i = begin + 1; i < end - 1; i++) { char c = s.charAt(i); if (c == '"') { inQuotes = !inQuotes; } else if (inQuotes) { if (c == '@') { if (s.startsWith("@integer", i)) { appliesToIntegers = true; break; } else { // @decimal always comes after @integer break; } } else if ((c == 'i' || c == 'n') && Character .isWhitespace(s.charAt(i + 1))) { appliesToIntegers = true; break; } } } if (!appliesToIntegers) { if (DEBUG) { System.out.println("Skipping quantity " + s.substring(offset, begin) + " in set for locale " + language + " (" + getSetName(language) + ")"); } continue; } if (s.startsWith("one{", offset)) { set.add(one); } else if (s.startsWith("few{", offset)) { set.add(few); } else if (s.startsWith("many{", offset)) { set.add(many); } else if (s.startsWith("two{", offset)) { set.add(two); } else if (s.startsWith("zero{", offset)) { set.add(zero); } else { // Unexpected quantity: ignore if (DEBUG) { assert false : s.substring(offset, Math.min(offset + 10, length)); } } } mPlurals.put(language, set); } return set == NONE ? null : set; } public boolean hasMultipleValuesForQuantity( @NonNull String language, @NonNull Quantity quantity) { if (quantity == Quantity.one || quantity == Quantity.two || quantity == Quantity.zero) { ensureInitialized(); String setName = getSetName(language); if (setName != null) { Set<String> names = mMultiValueSetNames.get(quantity); assert names != null : quantity; return names.contains(setName); } } return false; } private void ensureInitialized() { if (mPlurals == null) { initialize(); } } @SuppressWarnings({"UnnecessaryLocalVariable", "UnusedDeclaration"}) private void initialize() { // Sets where more than a single integer maps to the quantity. Take for example // set 10: // set10{ // one{ // "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81," // " 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0" // ", 101.0, 1001.0, …" // } // } // Here we see that both "1" and "21" will match the "one" category. // Note that this only applies to integers (since getQuantityString only takes integer) // whereas the plurals data also covers fractions. I was not sure what to do about // set17: // set17{ // one{"i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6"} // } // since it looks to me like this only differs from 1 in the fractional part. // // This is encoded by looking at the rules; this is done by the unit test // testDeriveMultiValueSetNames() (which ensures that the set is correct and if // not computes the correct set of set names to use for the current plurals.txt // database. mMultiValueSetNames = Maps.newEnumMap(Quantity.class); mMultiValueSetNames.put(Quantity.two, Sets.newHashSet("set21", "set22", "set30", "set32")); mMultiValueSetNames.put(Quantity.one, Sets.newHashSet( "set1", "set11", "set12", "set13", "set14", "set2", "set20", "set21", "set22", "set26", "set27", "set29", "set30", "set32", "set5", "set6")); mMultiValueSetNames.put(Quantity.zero, Sets.newHashSet("set14")); mSetNamePerLanguage = Maps.newHashMapWithExpectedSize(20); mPlurals = Maps.newHashMapWithExpectedSize(20); } @Nullable public String findIntegerExamples(@NonNull String language, @NonNull Quantity quantity) { String data = getQuantityData(language, quantity); if (data != null) { int index = data.indexOf("@integer"); if (index == -1) { return null; } int start = index + "@integer".length(); int end = data.indexOf('@', start); if (end == -1) { end = data.length(); } return data.substring(start, end).trim(); } return null; } @NonNull private String getPluralsDescriptions() { if (mDescriptions == null) { InputStream stream = PluralsDatabaseTest.class.getResourceAsStream("data/plurals.txt"); if (stream != null) { try { byte[] bytes = ByteStreams.toByteArray(stream); mDescriptions = new String(bytes, Charsets.UTF_8); mRuleSetOffset = mDescriptions.indexOf("rules{"); if (mRuleSetOffset == -1) { if (DEBUG) { assert false; } mDescriptions = ""; mRuleSetOffset = 0; } } catch (IOException e) { try { stream.close(); } catch (IOException e1) { // Stupid API. } } } if (mDescriptions == null) { mDescriptions = ""; } } return mDescriptions; } @Nullable public String getQuantityData(@NonNull String language, @NonNull Quantity quantity) { String data = getLocaleData(language); if (data == null) { return null; } String quantityDeclaration = quantity.name() + "{"; int quantityStart = data.indexOf(quantityDeclaration); if (quantityStart == -1) { return null; } int quantityEnd = findBalancedEnd(data, quantityStart); if (quantityEnd == -1) { return null; } //String s = data.substring(quantityStart + quantityDeclaration.length(), quantityEnd); StringBuilder sb = new StringBuilder(); boolean inString = false; for (int i = quantityStart + quantityDeclaration.length(); i < quantityEnd; i++) { char c = data.charAt(i); if (c == '"') { inString = !inString; } else if (inString) { sb.append(c); } } return sb.toString(); } @Nullable public String getSetName(@NonNull String language) { String name = mSetNamePerLanguage.get(language); if (name == null) { name = findSetName(language); if (name == null) { name = ""; // Store "" instead of null so we remember search result } mSetNamePerLanguage.put(language, name); } return name.isEmpty() ? null : name; } @Nullable private String findSetName(@NonNull String language) { String data = getPluralsDescriptions(); int index = data.indexOf("locales{"); if (index == -1) { return null; } int end = data.indexOf("locales_ordinals{", index + 1); if (end == -1) { return null; } String languageDeclaration = " " + language + "{\""; index = data.indexOf(languageDeclaration); if (index == -1 || index >= end) { return null; } int setEnd = data.indexOf('\"', index + languageDeclaration.length()); if (setEnd == -1) { return null; } return data.substring(index + languageDeclaration.length(), setEnd).trim(); } @Nullable public String getLocaleData(@NonNull String language) { String set = getSetName(language); if (set == null) { return null; } String data = getPluralsDescriptions(); int setStart = data.indexOf(set + "{", mRuleSetOffset); if (setStart == -1) { return null; } int setEnd = findBalancedEnd(data, setStart); if (setEnd == -1) { return null; } return data.substring(setStart + set.length() + 1, setEnd); } private static int findBalancedEnd(String data, int offset) { int balance = 0; int length = data.length(); for (; offset < length; offset++) { char c = data.charAt(offset); if (c == '{') { balance++; } else if (c == '}') { balance--; if (balance == 0) { return offset; } } } return -1; } } }