/** * Copyright (c) 2012-2016 AndrĂ© Bargull * Alle Rechte vorbehalten / All Rights Reserved. Use is subject to license terms. * * <https://github.com/anba/es6draft> */ package com.github.anba.es6draft.runtime.objects.intl; import static com.github.anba.es6draft.runtime.AbstractOperations.*; import static com.github.anba.es6draft.runtime.internal.Errors.newRangeError; import static com.github.anba.es6draft.runtime.internal.Errors.newTypeError; import static com.github.anba.es6draft.runtime.types.builtins.ArrayObject.ArrayCreate; import static java.util.Collections.emptyList; import static java.util.Collections.emptySet; import static java.util.Collections.singleton; import static java.util.Collections.singletonList; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import com.github.anba.es6draft.runtime.ExecutionContext; import com.github.anba.es6draft.runtime.Realm; import com.github.anba.es6draft.runtime.internal.Lazy; import com.github.anba.es6draft.runtime.internal.Messages; import com.github.anba.es6draft.runtime.objects.intl.LanguageTagParser.LanguageTag; import com.github.anba.es6draft.runtime.types.PropertyDescriptor; import com.github.anba.es6draft.runtime.types.ScriptObject; import com.github.anba.es6draft.runtime.types.Type; import com.github.anba.es6draft.runtime.types.builtins.ArrayObject; import com.ibm.icu.util.LocaleMatcher; import com.ibm.icu.util.LocalePriorityList; import com.ibm.icu.util.TimeZone; import com.ibm.icu.util.TimeZone.SystemTimeZoneType; import com.ibm.icu.util.ULocale; /** * <h1>9 Locale and Parameter Negotiation</h1><br> * <h2>9.2 Abstract Operations</h2> */ public final class IntlAbstractOperations { private IntlAbstractOperations() { } /** * 6.1 Case Sensitivity and Case Mapping * * @param s * the string * @return the upper case string */ public static String ToUpperCase(String s) { char[] ca = s.toCharArray(); for (int i = 0, len = ca.length; i < len; ++i) { char c = ca[i]; if ('a' <= c && c <= 'z') { c = (char) (c - ('a' - 'A')); } ca[i] = c; } return new String(ca); } /** * */ public static final class LanguageTagUnicodeExtension { final String languageTag; final int startIndex; final int endIndex; LanguageTagUnicodeExtension(String languageTag) { this.languageTag = languageTag; this.startIndex = findStartIndex(languageTag); this.endIndex = findEndIndex(languageTag, startIndex); } /** * Returns the language tag with the unicode extension sequence removed. * * @return the language tag with the unicode extension sequence removed */ public String getLanguageTag() { return removeUnicodeExtension(languageTag, startIndex, endIndex); } /** * Returns the unicode extension sequence. * * @return the unicode extension sequence */ public String getUnicodeExtension() { if (startIndex == -1) { return ""; } return languageTag.substring(startIndex, endIndex); } int getSubtagStartIndex() { if (startIndex == -1) { return -1; } return startIndex + 2; } static String removeExtension(String languageTag) { int startIndex = findStartIndex(languageTag); int endIndex = findEndIndex(languageTag, startIndex); return removeUnicodeExtension(languageTag, startIndex, endIndex); } private static String removeUnicodeExtension(String languageTag, int startIndex, int endIndex) { if (startIndex == -1) { return languageTag; } if (endIndex == languageTag.length()) { return languageTag.substring(0, startIndex); } return languageTag.substring(0, startIndex) + languageTag.substring(endIndex); } private static int findStartIndex(String languageTag) { if (languageTag.startsWith("x-")) { // privateuse-only case return -1; } int indexUnicode = languageTag.indexOf("-u-"); if (indexUnicode == -1) { // no unicode extension return -1; } int indexPrivateUse = languageTag.lastIndexOf("-x-", indexUnicode); if (indexPrivateUse != -1) { // -u- in privateuse case return -1; } return indexUnicode; } private static int findEndIndex(String languageTag, int indexUnicode) { // found unicode extension, search end index if (indexUnicode == -1) { return -1; } int endIndex = languageTag.length(); for (int i = indexUnicode + 3;;) { int sep = languageTag.indexOf('-', i); if (sep == -1) { // end of string reached break; } assert sep + 2 < languageTag.length() : languageTag; if (languageTag.charAt(sep + 2) == '-') { // next singleton found endIndex = sep; break; } i = sep + 1; } return endIndex; } } /** * 6.2.1 Unicode Locale Extension Sequences * * @param languageTag * the canonicalized language tag * @return the pair {@code [languageTagWithoutExtension, unicodeExtension]} */ public static LanguageTagUnicodeExtension UnicodeLocaleExtSequence(String languageTag) { return new LanguageTagUnicodeExtension(languageTag); } /** * 6.2.1 Unicode Locale Extension Sequences * * @param languageTag * the canonicalized language tag * @return the language tag with the unicode extension sequence removed */ public static String RemoveUnicodeLocaleExtension(String languageTag) { return LanguageTagUnicodeExtension.removeExtension(languageTag); } /** * 6.2.2 IsStructurallyValidLanguageTag (locale) * * @param locale * the locale string * @return the parsed language tag or {@code null} if the locale is not a valid language tag */ public static LanguageTag IsStructurallyValidLanguageTag(String locale) { return LanguageTagParser.parse(locale); } /** * 6.2.3 CanonicalizeLanguageTag (locale) * * @param locale * the language tag * @return the canonicalized language tag */ public static String CanonicalizeLanguageTag(LanguageTag locale) { return locale.canonicalize(); } @SuppressWarnings("serial") private static final class ValidLanguageTags extends LinkedHashMap<String, String> { private static final int MAX_SIZE = 12; ValidLanguageTags() { super(16, 0.75f, true); } @Override protected boolean removeEldestEntry(Map.Entry<String, String> eldest) { return size() > MAX_SIZE; } } private static final Map<String, String> validLanguageTags = new ValidLanguageTags(); static { validLanguageTags.put("en", "en"); validLanguageTags.put("en-GB", "en-GB"); validLanguageTags.put("en-US", "en-US"); } private static final String DEFAULT_LOCALE = "en"; private static String sanitizeLanguageTag(String languageTag) { LanguageTag tag = IsStructurallyValidLanguageTag(languageTag); if (tag == null) { return DEFAULT_LOCALE; } String locale = RemoveUnicodeLocaleExtension(tag.canonicalize()); locale = BestLocale(GetAvailableLocales(LanguageData.getAvailableCollatorLocales()), locale); if (locale == null) { return DEFAULT_LOCALE; } locale = BestLocale(GetAvailableLocales(LanguageData.getAvailableDateFormatLocales()), locale); if (locale == null) { return DEFAULT_LOCALE; } locale = BestLocale(GetAvailableLocales(LanguageData.getAvailableNumberFormatLocales()), locale); if (locale == null) { return DEFAULT_LOCALE; } return locale; } private static String BestLocale(Set<String> availableLocales, String locale) { if (BEST_FIT_SUPPORTED) { return BestFitAvailableLocale(availableLocales, locale); } return BestAvailableLocale(availableLocales, locale); } /** * 6.2.4 DefaultLocale () * * @param realm * the realm instance * @return the default locale */ public static String DefaultLocale(Realm realm) { String languageTag = realm.getLocale().toLanguageTag(); synchronized (validLanguageTags) { String valid = validLanguageTags.get(languageTag); if (valid == null) { validLanguageTags.put(languageTag, valid = sanitizeLanguageTag(languageTag)); } return valid; } } /** * 6.3.1 IsWellFormedCurrencyCode (currency) * * @param currency * the currency string * @return {@code true} if the currency string is well formed */ public static boolean IsWellFormedCurrencyCode(String currency) { /* step 1 (case normalization omitted) */ String normalized = currency; /* step 2 */ if (normalized.length() != 3) { return false; } /* steps 3-4 */ return isAlpha(normalized.charAt(0)) && isAlpha(normalized.charAt(1)) && isAlpha(normalized.charAt(2)); } private static boolean isAlpha(char c) { return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z'); } private static final Set<String> JDK_TIMEZONE_NAMES = set("ACT", "AET", "AGT", "ART", "AST", "BET", "BST", "CAT", "CNT", "CST", "CTT", "EAT", "ECT", "IET", "IST", "JST", "MIT", "NET", "NST", "PLT", "PNT", "PRT", "PST", "SST", "VST"); private static final Lazy<HashMap<String, String>> timezones = Lazy.syncOf(() -> { HashMap<String, String> map = new HashMap<>(); for (String id : TimeZone.getAvailableIDs(SystemTimeZoneType.ANY, null, null)) { if (JDK_TIMEZONE_NAMES.contains(id)) { // ignore non-IANA, JDK-specific timezones continue; } map.put(ToUpperCase(id), id); } return map; }); /** * 6.4.1 IsValidTimeZoneName (timeZone) * * @param timeZone * the time zone name * @return {@code true} if the time zone name is valid */ public static boolean IsValidTimeZoneName(String timeZone) { return timezones.get().containsKey(ToUpperCase(timeZone)); } /** * 6.4.2 CanonicalizeTimeZoneName (timeZone) * * @param timeZone * the time zone name * @return the canonicalized time zone name */ public static String CanonicalizeTimeZoneName(String timeZone) { /* step 1 */ String ianaTimeZone = timezones.get().get(ToUpperCase(timeZone)); /* step 2 */ ianaTimeZone = TimeZone.getCanonicalID(ianaTimeZone); assert ianaTimeZone != null : "invalid timezone: " + timeZone; /* step 3 */ if ("Etc/UTC".equals(ianaTimeZone) || "Etc/GMT".equals(ianaTimeZone)) { return "UTC"; } /* step 4 */ return ianaTimeZone; } /** * 6.4.3 DefaultTimeZone () * * @param realm * the realm instance * @return the default time zone */ public static String DefaultTimeZone(Realm realm) { return realm.getTimeZone().getID(); } private static final HashMap<String, String[]> oldStyleLanguageTags; static { // generated from CLDR-2.0.0 HashMap<String, String[]> map = new HashMap<>(); map.put("az-Latn-AZ", new String[] { "az-AZ" }); map.put("ha-Latn-GH", new String[] { "ha-GH" }); map.put("ha-Latn-NE", new String[] { "ha-NE" }); map.put("ha-Latn-NG", new String[] { "ha-NG" }); map.put("kk-Cyrl-KZ", new String[] { "kk-KZ" }); map.put("ku-Arab-IQ", new String[] { "ku-IQ" }); map.put("ku-Arab-IR", new String[] { "ku-IR" }); map.put("ku-Latn-SY", new String[] { "ku-SY" }); map.put("ku-Latn-TR", new String[] { "ku-TR" }); map.put("mn-Mong-CN", new String[] { "mn-CN" }); map.put("mn-Cyrl-MN", new String[] { "mn-MN" }); map.put("pa-Guru-IN", new String[] { "pa-IN" }); map.put("pa-Arab-PK", new String[] { "pa-PK" }); map.put("shi-Latn-MA", new String[] { "shi-MA" }); map.put("sr-Latn-BA", new String[] { "sh-BA" }); map.put("sr-Latn-RS", new String[] { "sh-CS", "sh-YU" }); map.put("sr-Cyrl-BA", new String[] { "sr-BA" }); map.put("sr-Cyrl-RS", new String[] { "sr-CS", "sr-RS", "sr-YU" }); map.put("sr-Latn-ME", new String[] { "sr-ME" }); map.put("tg-Cyrl-TJ", new String[] { "tg-TJ" }); map.put("fil-PH", new String[] { "tl-PH" }); map.put("tzm-Latn-MA", new String[] { "tzm-MA" }); map.put("uz-Arab-AF", new String[] { "uz-AF" }); map.put("uz-Cyrl-UZ", new String[] { "uz-UZ" }); map.put("vai-Vaii-LR", new String[] { "vai-LR" }); map.put("zh-Hans-CN", new String[] { "zh-CN" }); map.put("zh-Hant-HK", new String[] { "zh-HK" }); map.put("zh-Hant-MO", new String[] { "zh-MO" }); map.put("zh-Hans-SG", new String[] { "zh-SG" }); map.put("zh-Hant-TW", new String[] { "zh-TW" }); oldStyleLanguageTags = map; } /** * 9.1 Internal Properties of Service Constructors * * @param locales * the supported locales * @return the set of available locales */ public static Set<String> GetAvailableLocales(Collection<String> locales) { HashMap<String, String[]> oldTags = oldStyleLanguageTags; HashSet<String> available = new LRUHashSet(locales); for (Map.Entry<String, String[]> oldTag : oldTags.entrySet()) { if (available.contains(oldTag.getKey())) { available.addAll(Arrays.asList(oldTag.getValue())); } } return available; } @SuppressWarnings("serial") private static final class LRUHashSet extends HashSet<String> { private transient String lastKey; private transient BestFitMatch lastValue; LRUHashSet(Collection<String> c) { super(c); } static LRUHashSet from(Set<String> set) { return set instanceof LRUHashSet ? (LRUHashSet) set : null; } static BestFitMatch get(LRUHashSet set, String key) { if (set != null && key.equals(set.lastKey)) { return set.lastValue; } return null; } static void set(LRUHashSet set, String key, BestFitMatch value) { if (set != null) { set.lastKey = key; set.lastValue = value; } } } public enum ExtensionKey { /** calendar */ ca, /** collation */ co, /** colCaseFirst */ kf, /** colNumeric */ kn, /** numbers */ nu; static ExtensionKey forName(String name, int index) { assert index + 2 <= name.length(); char c0 = name.charAt(index), c1 = name.charAt(index + 1); if (c0 == 'c') { return c1 == 'a' ? ca : c1 == 'o' ? co : null; } if (c0 == 'k') { return c1 == 'f' ? kf : c1 == 'n' ? kn : null; } return c0 == 'n' && c1 == 'u' ? nu : null; } } /** * 9.1 Internal Properties of Service Constructors */ public interface LocaleData { LocaleDataInfo info(ULocale locale); } /** * 9.1 Internal Properties of Service Constructors */ public interface LocaleDataInfo { /** * Returns {@link #entries(IntlAbstractOperations.ExtensionKey)}.get(0). * * @param extensionKey * the extension key * @return the extension key default value */ String defaultValue(ExtensionKey extensionKey); /** * Returns [[sortLocaleData]], [[searchLocaleData]] or [[localeData]]. * * @param extensionKey * the extension key * @return the list of extension key entries */ List<String> entries(ExtensionKey extensionKey); } /** * 9.2.1 CanonicalizeLocaleList (locales) * * @param cx * the execution context * @param locales * the locales array * @return the set of canonicalized locales */ public static Set<String> CanonicalizeLocaleList(ExecutionContext cx, Object locales) { /* step 1 */ if (Type.isUndefined(locales)) { return emptySet(); } /* steps 2-8 (string only) */ if (Type.isString(locales)) { // handle the string-only case directly String tag = ToFlatString(cx, locales); LanguageTag langTag = IsStructurallyValidLanguageTag(tag); if (langTag == null) { throw newRangeError(cx, Messages.Key.IntlStructurallyInvalidLanguageTag, tag); } tag = CanonicalizeLanguageTag(langTag); return singleton(tag); } /* step 2 */ LinkedHashSet<String> seen = new LinkedHashSet<>(); /* step 3 (not applicable) */ /* step 4 */ ScriptObject o = ToObject(cx, locales); /* step 5 */ long len = ToLength(cx, Get(cx, o, "length")); /* steps 6-7 */ for (long k = 0; k < len; ++k) { /* step 7.a */ long pk = k; /* step 7.b */ boolean kPresent = HasProperty(cx, o, pk); /* step 7.c */ if (kPresent) { /* step 7.c.i */ Object kValue = Get(cx, o, pk); /* step 7.c.ii */ if (!(Type.isString(kValue) || Type.isObject(kValue))) { throw newTypeError(cx, Messages.Key.IntlInvalidLanguageTagType, Type.of(kValue).toString()); } /* step 7.c.iii */ String tag = ToFlatString(cx, kValue); /* step 7.c.iv */ LanguageTag langTag = IsStructurallyValidLanguageTag(tag); if (langTag == null) { throw newRangeError(cx, Messages.Key.IntlStructurallyInvalidLanguageTag, tag); } /* step 7.c.v */ tag = CanonicalizeLanguageTag(langTag); /* step 7.c.vi */ seen.add(tag); } } /* step 8 */ return seen; } /** * 9.2.2 BestAvailableLocale (availableLocales, locale) * * @param availableLocales * the set of available locales * @param locale * the requested locale * @return the best available locale */ public static String BestAvailableLocale(Set<String> availableLocales, String locale) { /* step 1 */ String candidate = locale; /* step 2 */ while (true) { /* step 2.a */ if (availableLocales.contains(candidate)) { return candidate; } /* step 2.b */ int pos = candidate.lastIndexOf('-'); if (pos == -1) { return null; } /* step 2.c */ if (pos >= 2 && candidate.charAt(pos - 2) == '-') { pos -= 2; } /* step 2.d */ candidate = candidate.substring(0, pos); } } /** * The result record type returned by BestFitMatcher and LookupMatcher. */ public static final class LocaleMatch { /** [[locale]] */ private final String locale; /** [[extension]] */ private final String extension; /** [[extensionIndex]] */ private final int extensionIndex; LocaleMatch(String locale) { this.locale = locale; this.extension = ""; this.extensionIndex = -1; } LocaleMatch(String locale, LanguageTagUnicodeExtension unicodeExt) { this.locale = locale; this.extension = unicodeExt.getUnicodeExtension(); this.extensionIndex = unicodeExt.getSubtagStartIndex(); } /** * [[locale]] * * @return the locale language tag */ public String getLocale() { return locale; } /** * [[extension]] * * @return the language extension tag */ public String getExtension() { return extension; } /** * [[extensionIndex]] * * @return the start index of the language extension tag */ public int getExtensionIndex() { return extensionIndex; } } /** * 9.2.3 LookupMatcher (availableLocales, requestedLocales) * * @param realm * the realm instance * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @return the lookup matcher */ public static LocaleMatch LookupMatcher(Realm realm, Lazy<Set<String>> availableLocales, Set<String> requestedLocales) { /* steps 1-5 */ for (String locale : requestedLocales) { /* step 5.a (not applicable) */ /* step 5.b */ LanguageTagUnicodeExtension unicodeExt = UnicodeLocaleExtSequence(locale); String noExtensionsLocale = unicodeExt.getLanguageTag(); /* step 5.c */ String availableLocale = BestAvailableLocale(availableLocales.get(), noExtensionsLocale); if (availableLocale != null) { /* steps 6-7, 9 */ return new LocaleMatch(availableLocale, unicodeExt); } /* step 5.d (not applicable) */ } /* steps 6, 8-9 */ return new LocaleMatch(DefaultLocale(realm)); } /** * 9.2.4 BestFitMatcher (availableLocales, requestedLocales) * * @param realm * the realm instance * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @return the best-fit matcher */ public static LocaleMatch BestFitMatcher(Realm realm, Lazy<Set<String>> availableLocales, Set<String> requestedLocales) { if (!BEST_FIT_SUPPORTED) { return LookupMatcher(realm, availableLocales, requestedLocales); } // fast path when no specific locale was requested if (requestedLocales.isEmpty()) { return new LocaleMatch(DefaultLocale(realm)); } // fast path Set<String> available = availableLocales.get(); for (String locale : requestedLocales) { LanguageTagUnicodeExtension unicodeExt = UnicodeLocaleExtSequence(locale); String noExtensionsLocale = unicodeExt.getLanguageTag(); if (available.contains(noExtensionsLocale)) { return new LocaleMatch(noExtensionsLocale, unicodeExt); } } // search for best fit match LocaleMatcher matcher = CreateDefaultMatcher(); BestFitMatch bestMatch = null; String bestMatchCandidate = null; for (String locale : requestedLocales) { String noExtLocale = RemoveUnicodeLocaleExtension(locale); BestFitMatch match = BestFitAvailableLocale(matcher, available, noExtLocale); double score = match.score; if (score >= BEST_FIT_MIN_MATCH && (bestMatch == null || score > bestMatch.score)) { // System.out.printf("%s -> %s [%f]%n", locale, match.locale, match.score); bestMatch = match; bestMatchCandidate = locale; if (score == 1.0) { break; } } } // If no best fit match was found, fall back to lookup matcher algorithm. if (bestMatch == null) { return LookupMatcher(realm, availableLocales, requestedLocales); } return new LocaleMatch(bestMatch.locale, UnicodeLocaleExtSequence(bestMatchCandidate)); } /** * Minimum match value for best fit matcher, currently set to 0.5 to match ICU4J's defaults. */ private static final double BEST_FIT_MIN_MATCH = 0.5; private static final boolean BEST_FIT_SUPPORTED = true; private static LocaleMatcher CreateDefaultMatcher() { LocalePriorityList priorityList = LocalePriorityList.add(ULocale.ROOT).build(); return new LocaleMatcher(priorityList); } /** * Hard cache to reduce time required to finish intl-tests. The cache size is limited by the total number of * available locales. */ private static final ConcurrentHashMap<String, LocaleEntry> maximizedLocales = new ConcurrentHashMap<>(); private static LocaleEntry GetMaximizedLocale(LocaleMatcher matcher, String locale) { LocaleEntry entry = maximizedLocales.get(locale); if (entry == null) { entry = createLocaleEntry(matcher, locale); maximizedLocales.put(locale, entry); } return entry; } private static final class LocaleEntry { private final ULocale canonicalized; private final ULocale maximized; LocaleEntry(ULocale canonicalized, ULocale maximized) { this.canonicalized = canonicalized; this.maximized = maximized; } ULocale getCanonicalized() { return canonicalized; } ULocale getMaximized() { return maximized; } } private static LocaleEntry createLocaleEntry(LocaleMatcher matcher, String locale) { ULocale canonicalized = matcher.canonicalize(ULocale.forLanguageTag(locale)); ULocale maximized = addLikelySubtagsWithDefaults(canonicalized); return new LocaleEntry(canonicalized, maximized); } private static final class BestFitMatch { final String locale; final double score; BestFitMatch(String locale, double score) { this.locale = locale; this.score = score; } } private static BestFitMatch BestFitAvailableLocale(LocaleMatcher matcher, Set<String> availableLocales, String requestedLocale) { // Return early if requested locale is available as-is. if (availableLocales.contains(requestedLocale)) { return new BestFitMatch(requestedLocale, 1.0); } // Check cache next. LRUHashSet lruAvailableLocales = LRUHashSet.from(availableLocales); BestFitMatch lastResolved = LRUHashSet.get(lruAvailableLocales, requestedLocale); if (lastResolved != null) { return lastResolved; } // Perform two-passes to compute best-fit available locale: // 1) Compare maximized locales. // 2) If no match was found, compare all available locales. LocaleEntry requested = createLocaleEntry(matcher, requestedLocale); String bestMatchLocale = null; LocaleEntry bestMatchEntry = null; double bestMatch = Double.NEGATIVE_INFINITY; for (String available : availableLocales) { LocaleEntry entry = GetMaximizedLocale(matcher, available); if (requested.maximized.equals(entry.maximized)) { double match = matcher.match(requested.getCanonicalized(), requested.getMaximized(), entry.getCanonicalized(), entry.getMaximized()); if (match > bestMatch || (match == bestMatch && isBetterMatch(requested, bestMatchEntry, entry))) { bestMatchLocale = available; bestMatchEntry = entry; bestMatch = match; } } } if (bestMatchLocale == null) { for (String available : availableLocales) { LocaleEntry entry = GetMaximizedLocale(matcher, available); double match = matcher.match(requested.getCanonicalized(), requested.getMaximized(), entry.getCanonicalized(), entry.getMaximized()); // if (match > 0.10) { // System.out.printf("[%s; %s, %s] -> [%s; %s, %s] => %f%n", requestedLocale, // requested.getCanonicalized(), requested.getMaximized(), available, // entry.getCanonicalized(), entry.getMaximized(), match); // } if (match > bestMatch || (match == bestMatch && isBetterMatch(requested, bestMatchEntry, entry))) { bestMatchLocale = available; bestMatchEntry = entry; bestMatch = match; } } } // Create result object and store in cache. BestFitMatch result = new BestFitMatch(bestMatchLocale, bestMatch); LRUHashSet.set(lruAvailableLocales, requestedLocale, result); return result; } private static String BestFitAvailableLocale(Set<String> availableLocales, String requestedLocale) { if (availableLocales.contains(requestedLocale)) { return requestedLocale; } LocaleMatcher matcher = CreateDefaultMatcher(); BestFitMatch availableLocaleMatch = BestFitAvailableLocale(matcher, availableLocales, requestedLocale); if (availableLocaleMatch.score >= BEST_FIT_MIN_MATCH) { return availableLocaleMatch.locale; } else { // If no best fit match was found, fall back to lookup matcher algorithm String availableLocale = BestAvailableLocale(availableLocales, requestedLocale); if (availableLocale != null) { return availableLocale; } } return null; } /** * Requests for "en-US" give two results with a perfect score: * <ul> * <li>[en; en, en_Latn_US] * <li>[en-US; en_US, en_Latn_US] * </ul> * Prefer the more detailed result value, i.e. "en-US". * * @param requested * the requested locale * @param oldMatch * the previous match * @param newMatch * the new match * @return {@code true} if the new match is better than the previous one */ private static boolean isBetterMatch(LocaleEntry requested, LocaleEntry oldMatch, LocaleEntry newMatch) { ULocale canonicalized = requested.getCanonicalized(); ULocale oldCanonicalized = oldMatch.getCanonicalized(); ULocale newCanonicalized = newMatch.getCanonicalized(); // Compare language. String language = canonicalized.getLanguage(); String newLanguage = newCanonicalized.getLanguage(); String oldLanguage = oldCanonicalized.getLanguage(); assert !newLanguage.isEmpty() && !oldLanguage.isEmpty(); if (newLanguage.equals(language) && !oldLanguage.equals(language)) { return true; } // Compare script. String script = canonicalized.getScript(); String newScript = newCanonicalized.getScript(); String oldScript = oldCanonicalized.getScript(); if ((newScript.equals(script) && !oldScript.equals(script)) || (newScript.isEmpty() && !oldScript.isEmpty() && !oldScript.equals(script))) { return true; } // Compare region. String region = canonicalized.getCountry(); String newRegion = newCanonicalized.getCountry(); String oldRegion = oldCanonicalized.getCountry(); if ((newRegion.equals(script) && !oldRegion.equals(region)) || (newRegion.isEmpty() && !oldRegion.isEmpty() && !oldRegion.equals(region))) { return true; } return false; } private static ULocale addLikelySubtagsWithDefaults(ULocale locale) { ULocale maximized = ULocale.addLikelySubtags(locale); if (maximized == locale) { // If already in maximal form or no data available for maximization, make sure // language, script and region are not undefined (ICU4J expects all are defined). String language = locale.getLanguage(); String script = locale.getScript(); String region = locale.getCountry(); if (language.isEmpty() || script.isEmpty() || region.isEmpty()) { return new ULocale(toLocaleId(language, script, region)); } } return maximized; } private static String toLocaleId(String language, String script, String region) { StringBuilder sb = new StringBuilder(); sb.append(!language.isEmpty() ? language : "und"); sb.append('_'); sb.append(!script.isEmpty() ? script : "Zzzz"); sb.append('_'); sb.append(!region.isEmpty() ? region : "ZZ"); return sb.toString(); } public static final class OptionsRecord { public enum MatcherType { Lookup, BestFit; public static MatcherType forName(String name) { switch (name) { case "lookup": return Lookup; case "best fit": return BestFit; default: throw new IllegalArgumentException(name); } } } /** [[localeMatcher]] */ private final MatcherType localeMatcher; private final EnumMap<ExtensionKey, String> values = new EnumMap<>(ExtensionKey.class); public OptionsRecord(MatcherType localeMatcher) { this.localeMatcher = localeMatcher; } /** * [[localeMatcher]] * * @return the matcher type */ public MatcherType getLocaleMatcher() { return localeMatcher; } public int size() { return values.size(); } public boolean contains(ExtensionKey key) { return values.containsKey(key); } public String get(ExtensionKey key) { return values.get(key); } public void set(ExtensionKey key, String value) { values.put(key, value); } } public static final class ResolvedLocale { private final String dataLocale; private final String locale; private final EnumMap<ExtensionKey, String> values; ResolvedLocale(String dataLocale, String locale, EnumMap<ExtensionKey, String> values) { this.dataLocale = dataLocale; this.locale = locale; this.values = values; } public String getDataLocale() { return dataLocale; } public String getLocale() { return locale; } public String getValue(ExtensionKey key) { return values.get(key); } } /** * 9.2.5 UnicodeExtensionSubtags (extension) * * @param extension * the Unicode locale extension sequence * @return the map <code>« extension key → extension value »</code> */ public static EnumMap<ExtensionKey, String> UnicodeExtensionSubtags(String extension) { /* * http://unicode.org/reports/tr35/#Unicode_locale_identifier * * unicode_locale_extensions = sep "u" ((sep keyword)+ | (sep attribute)+ (sep keyword)*) ; * keyword = key (sep type)? ; * key = alphanum{2} ; * type = alphanum{3,8} (sep alphanum{3,8})* ; * attribute = alphanum{3,8} ; */ final int KEY_LENGTH = 2; assert extension.startsWith("-u-") && extension.length() >= 3 + KEY_LENGTH : extension; final int length = extension.length(); int startKeyword = 3; // Skip optional leading attributes. while (startKeyword < length) { int index = extension.indexOf('-', startKeyword); int endIndex = index != -1 ? index : extension.length(); if (endIndex - startKeyword > KEY_LENGTH) { startKeyword = endIndex + 1; } else { break; } } // Read keywords. EnumMap<ExtensionKey, String> map = new EnumMap<>(ExtensionKey.class); while (startKeyword < length) { assert startKeyword + KEY_LENGTH <= length; assert startKeyword + KEY_LENGTH == length || extension.charAt(startKeyword + KEY_LENGTH) == '-'; ExtensionKey key = ExtensionKey.forName(extension, startKeyword); int startType = startKeyword + KEY_LENGTH + 1, nextKeyword = startType; while (nextKeyword < length) { int index = extension.indexOf('-', nextKeyword); int endIndex = index != -1 ? index : extension.length(); if (endIndex - nextKeyword > KEY_LENGTH) { nextKeyword = endIndex + 1; } else { break; } } // Supported extension key and not a duplicate keyword. if (key != null && !map.containsKey(key)) { if (startType < nextKeyword) { map.put(key, extension.substring(startType, nextKeyword - 1)); } else { map.put(key, ""); } } startKeyword = nextKeyword; } return map; } /** * 9.2.6 ResolveLocale (availableLocales, requestedLocales, options, relevantExtensionKeys, localeData) * * @param realm * the realm instance * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @param options * the options record * @param relevantExtensionKeys * the list of relevant extension keys * @param localeData * the locale data * @return the resolved locale */ public static ResolvedLocale ResolveLocale(Realm realm, Lazy<Set<String>> availableLocales, Set<String> requestedLocales, OptionsRecord options, List<ExtensionKey> relevantExtensionKeys, LocaleData localeData) { /* steps 1-4 */ OptionsRecord.MatcherType matcher = options.getLocaleMatcher(); LocaleMatch r; if (matcher == OptionsRecord.MatcherType.Lookup) { r = LookupMatcher(realm, availableLocales, requestedLocales); } else { r = BestFitMatcher(realm, availableLocales, requestedLocales); } // fast path for steps 5-16 String extension = r.getExtension(); if (extension.isEmpty() && options.size() == 0) { return ResolveLocaleNoExtension(r, relevantExtensionKeys, localeData); } /* step 5 */ String foundLocale = r.getLocale(); LocaleDataInfo foundLocaleData = localeData.info(ULocale.forLanguageTag(foundLocale)); /* step 6 */ EnumMap<ExtensionKey, String> extensionSubtags = null; if (!extension.isEmpty()) { extensionSubtags = UnicodeExtensionSubtags(extension); } /* steps 7-8 (not applicable) */ /* step 9 */ StringBuilder supportedExtension = new StringBuilder("-u"); /* steps 10-13 */ EnumMap<ExtensionKey, String> values = new EnumMap<>(ExtensionKey.class); for (ExtensionKey key : relevantExtensionKeys) { /* steps 13.a-b (not applicable) */ /* step 13.c */ List<String> keyLocaleData = foundLocaleData.entries(key); /* step 13.d */ String value = keyLocaleData.get(0); /* step 13.e */ String supportedExtensionAddition = ""; /* steps 13.f, 13.f.i-ii */ if (extensionSubtags != null && extensionSubtags.containsKey(key)) { /* steps 13.f.ii.1-2 */ if (!extensionSubtags.get(key).isEmpty()) { /* step 13.f.ii.1 */ String requestedValue = extensionSubtags.get(key); if (keyLocaleData.contains(requestedValue)) { value = requestedValue; supportedExtensionAddition = "-" + key.name() + "-" + value; } } else if (keyLocaleData.contains("true")) { /* step 13.f.ii.2 */ value = "true"; } } /* step 13.g */ if (options.contains(key)) { /* step 13.g.i */ String optionsValue = options.get(key); /* step 13.g.ii */ if (keyLocaleData.contains(optionsValue) && !optionsValue.equals(value)) { value = optionsValue; supportedExtensionAddition = ""; } } /* step 13.h */ values.put(key, value); /* step 13.i */ supportedExtension.append(supportedExtensionAddition); /* step 13.j (not applicable) */ } /* step 14 */ if (supportedExtension.length() > 2) { // FIXME: spec bug - https://bugs.ecmascript.org/show_bug.cgi?id=1456 /* step 14.a */ int privateIndex = foundLocale.indexOf("-x-"); /* steps 14.b-c */ if (privateIndex == -1) { /* step 14.b */ foundLocale += supportedExtension; } else { /* step 14.c */ /* step 14.c.i */ String preExtension = foundLocale.substring(0, privateIndex); /* step 14.c.ii */ String postExtension = foundLocale.substring(privateIndex); /* step 14.c.iii */ foundLocale = preExtension + supportedExtension + postExtension; } /* step 14.d */ LanguageTag languageTag = IsStructurallyValidLanguageTag(foundLocale); assert languageTag != null : foundLocale; /* step 14.e */ foundLocale = CanonicalizeLanguageTag(languageTag); } /* steps 15-16 */ return new ResolvedLocale(r.getLocale(), foundLocale, values); } /** * 9.2.6 ResolveLocale (availableLocales, requestedLocales, options, relevantExtensionKeys, localeData) * * @param realm * the realm record * @param relevantExtensionKeys * the list of relevant extension keys * @param localeData * the locale data * @return the resolved locale */ public static ResolvedLocale ResolveDefaultLocale(Realm realm, List<ExtensionKey> relevantExtensionKeys, LocaleData localeData) { /* steps 1-4 */ LocaleMatch r = new LocaleMatch(DefaultLocale(realm)); /* steps 5-17 */ return ResolveLocaleNoExtension(r, relevantExtensionKeys, localeData); } private static ResolvedLocale ResolveLocaleNoExtension(LocaleMatch r, List<ExtensionKey> relevantExtensionKeys, LocaleData localeData) { /* steps 1-4 (not applicable) */ /* step 5 */ LocaleDataInfo foundLocaleData = localeData.info(ULocale.forLanguageTag(r.getLocale())); /* steps 6-8 (not applicable) */ assert r.getExtension().isEmpty(); /* steps 9-13 */ EnumMap<ExtensionKey, String> values = new EnumMap<>(ExtensionKey.class); for (ExtensionKey key : relevantExtensionKeys) { values.put(key, foundLocaleData.defaultValue(key)); } /* step 14 (not applicable) */ /* steps 15-16 */ return new ResolvedLocale(r.getLocale(), r.getLocale(), values); } /** * 9.2.7 LookupSupportedLocales (availableLocales, requestedLocales) * * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @return the list of supported locales */ public static List<String> LookupSupportedLocales(Set<String> availableLocales, Set<String> requestedLocales) { int numberOfRequestedLocales = requestedLocales.size(); // Fast path for none requested locale. if (numberOfRequestedLocales == 0) { return emptyList(); } // Fast path for one requested locale. if (numberOfRequestedLocales == 1) { String locale = requestedLocales.iterator().next(); String noExtensionsLocale = RemoveUnicodeLocaleExtension(locale); String availableLocale = BestAvailableLocale(availableLocales, noExtensionsLocale); if (availableLocale != null) { return singletonList(locale); } return emptyList(); } /* steps 1-5 */ ArrayList<String> subset = new ArrayList<>(); for (String locale : requestedLocales) { /* step 5.a (not applicable) */ /* step 5.b */ String noExtensionsLocale = RemoveUnicodeLocaleExtension(locale); /* step 5.c */ String availableLocale = BestAvailableLocale(availableLocales, noExtensionsLocale); /* step 5.d */ if (availableLocale != null) { subset.add(locale); } /* step 5.e (not applicable) */ } /* step 6 */ return subset; } /** * 9.2.8 BestFitSupportedLocales (availableLocales, requestedLocales) * * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @return the list of best-fit supported locales */ public static List<String> BestFitSupportedLocales(Set<String> availableLocales, Set<String> requestedLocales) { if (!BEST_FIT_SUPPORTED) { return LookupSupportedLocales(availableLocales, requestedLocales); } int numberOfRequestedLocales = requestedLocales.size(); if (numberOfRequestedLocales == 0) { return emptyList(); } if (numberOfRequestedLocales == 1) { String locale = requestedLocales.iterator().next(); String noExtensionsLocale = RemoveUnicodeLocaleExtension(locale); if (BestFitAvailableLocale(availableLocales, noExtensionsLocale) != null) { return singletonList(locale); } return emptyList(); } LocaleMatcher matcher = CreateDefaultMatcher(); ArrayList<String> subset = new ArrayList<>(); for (String locale : requestedLocales) { String noExtensionsLocale = RemoveUnicodeLocaleExtension(locale); BestFitMatch availableLocaleMatch = BestFitAvailableLocale(matcher, availableLocales, noExtensionsLocale); if (availableLocaleMatch.score >= BEST_FIT_MIN_MATCH) { subset.add(locale); } else { // If no best fit match was found, fall back to lookup matcher algorithm String availableLocale = BestAvailableLocale(availableLocales, noExtensionsLocale); if (availableLocale != null) { subset.add(locale); } } } return subset; } /** * 9.2.9 SupportedLocales (availableLocales, requestedLocales, options) * * @param cx * the execution context * @param availableLocales * the set of available locales * @param requestedLocales * the set of requested locales * @param options * the options object * @return the supported locales array */ public static ScriptObject SupportedLocales(ExecutionContext cx, Set<String> availableLocales, Set<String> requestedLocales, Object options) { /* step 1 */ String matcher = null; if (!Type.isUndefined(options)) { matcher = GetStringOption(cx, ToObject(cx, options), "localeMatcher", set("lookup", "best fit"), "best fit"); } /* steps 2-5 */ List<String> supportedLocales; if (matcher == null || "best fit".equals(matcher)) { supportedLocales = BestFitSupportedLocales(availableLocales, requestedLocales); } else { supportedLocales = LookupSupportedLocales(availableLocales, requestedLocales); } /* steps 6-8 */ ArrayObject subset = ArrayCreate(cx, supportedLocales.size()); int index = 0; for (Object value : supportedLocales) { subset.defineOwnProperty(cx, index++, new PropertyDescriptor(value, false, true, false)); } PropertyDescriptor nonConfigurableWritable = new PropertyDescriptor(); nonConfigurableWritable.setConfigurable(false); nonConfigurableWritable.setWritable(false); subset.defineOwnProperty(cx, "length", nonConfigurableWritable); /* step 9 */ return subset; } /** * 9.2.10 GetOption (options, property, type, values, fallback) * * @param cx * the execution context * @param options * the options object * @param property * the property name * @param values * the optional set of allowed property values * @param fallback * the fallback value * @return the string option */ public static String GetStringOption(ExecutionContext cx, ScriptObject options, String property, Set<String> values, String fallback) { /* step 1 (not applicable) */ /* step 2 */ Object value = Get(cx, options, property); /* step 3 */ if (!Type.isUndefined(value)) { /* steps 3.a-b (not applicable) */ /* step 3.c */ String val = ToFlatString(cx, value); /* step 3.d */ if (values != null && !values.contains(val)) { throw newRangeError(cx, Messages.Key.IntlInvalidOption, val); } /* step 3.e */ return val; } /* step 4 */ return fallback; } /** * 9.2.10 GetOption (options, property, type, values, fallback) * * @param cx * the execution context * @param options * the options object * @param property * the property name * @param fallback * the fallback value * @return the boolean option */ public static Boolean GetBooleanOption(ExecutionContext cx, ScriptObject options, String property, Boolean fallback) { /* step 1 (not applicable) */ /* step 2 */ Object value = Get(cx, options, property); /* step 3 */ if (!Type.isUndefined(value)) { /* steps 3.a, c-d (not applicable) */ /* steps 3.b, e */ return ToBoolean(value); } /* step 4 */ return fallback; } /** * 9.2.11 GetNumberOption (options, property, minimum, maximum, fallback) * * @param cx * the execution context * @param options * the options object * @param property * the property name * @param minimum * the minimum value * @param maximum * the maximum value * @param fallback * the fallback value * @return the number option */ public static int GetNumberOption(ExecutionContext cx, ScriptObject options, String property, int minimum, int maximum, int fallback) { assert minimum <= maximum; assert minimum <= fallback && fallback <= maximum; /* step 1 (not applicable) */ /* step 2 */ Object value = Get(cx, options, property); /* step 3 */ if (!Type.isUndefined(value)) { /* step 3.a */ double val = ToNumber(cx, value); /* step 3.b */ if (Double.isNaN(val) || val < minimum || val > maximum) { throw newRangeError(cx, Messages.Key.IntlInvalidOption, Double.toString(val)); } /* step 3.c */ return (int) Math.floor(val); } /* step 4 */ return fallback; } @SafeVarargs private static <T> Set<T> set(T... elements) { return new HashSet<>(Arrays.asList(elements)); } }