001/*
002 * Copyright 2017 Product Mog LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.lokalized;
018
019import com.lokalized.LocalizedString.LanguageFormTranslation;
020import com.lokalized.LocalizedString.LanguageFormTranslationRange;
021
022import javax.annotation.Nonnull;
023import javax.annotation.Nullable;
024import javax.annotation.concurrent.Immutable;
025import javax.annotation.concurrent.NotThreadSafe;
026import javax.annotation.concurrent.ThreadSafe;
027import java.util.ArrayList;
028import java.util.Collections;
029import java.util.HashMap;
030import java.util.HashSet;
031import java.util.LinkedHashSet;
032import java.util.List;
033import java.util.Locale;
034import java.util.Locale.LanguageRange;
035import java.util.Map;
036import java.util.Map.Entry;
037import java.util.Objects;
038import java.util.Optional;
039import java.util.Set;
040import java.util.concurrent.ConcurrentHashMap;
041import java.util.function.Supplier;
042import java.util.logging.Level;
043import java.util.logging.Logger;
044import java.util.stream.Collectors;
045
046import static java.lang.String.format;
047import static java.util.Objects.requireNonNull;
048
049/**
050 * Default implementation of a localized string provider.
051 * <p>
052 * It is recommended to use a single instance of this class across your entire application.
053 * <p>
054 * In multi-tenant systems like a web application where each user might have a different locale,
055 * your {@code localeSupplier} might return the locale specified by current request.
056 *
057 * @author <a href="https://revetkn.com">Mark Allen</a>
058 */
059@ThreadSafe
060public class DefaultStrings implements Strings {
061  @Nonnull
062  private final String fallbackLanguageCode;
063  @Nonnull
064  private final Map<Locale, Set<LocalizedString>> localizedStringsByLocale;
065  @Nullable
066  private final Supplier<Locale> localeSupplier;
067  @Nullable
068  private final Supplier<List<LanguageRange>> languageRangesSupplier;
069  @Nonnull
070  private final FailureMode failureMode;
071  @Nonnull
072  private final Locale fallbackLocale;
073  @Nonnull
074  private final StringInterpolator stringInterpolator;
075  @Nonnull
076  private final ExpressionEvaluator expressionEvaluator;
077  @Nonnull
078  private final Logger logger;
079
080  /**
081   * Cache of localized strings by key by locale.
082   * <p>
083   * This is our "master" reference localized string storage that other data structures will point to.
084   */
085  @Nonnull
086  private final Map<Locale, Map<String, LocalizedString>> localizedStringsByKeyByLocale;
087
088  /**
089   * Cache of best-matching strings for the given locale (populated on-demand per request at runtime).
090   * <p>
091   * List elements are ordered by most to least specific, e.g. if your locale is {@code en-US}, the first list element
092   * might be {@code en-US} strings and the second would be {@code en} strings.
093   * <p>
094   * There will always be at least one element in the list - the fallback locale.
095   */
096  @Nonnull
097  private final ConcurrentHashMap<Locale, List<LocalizedStringSource>> localizedStringSourcesByLocale;
098
099  /**
100   * Constructs a localized string provider with builder-supplied data.
101   * <p>
102   * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code.
103   * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used.
104   *
105   * @param fallbackLanguageCode    fallback language code, not null
106   * @param localizedStringSupplier supplier of localized strings, not null
107   * @param localeSupplier          locale supplier, may be null
108   * @param languageRangesSupplier  language ranges supplier, may be null
109   * @param failureMode             strategy for dealing with lookup failures, may be null
110   */
111  protected DefaultStrings(@Nonnull String fallbackLanguageCode,
112                           @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier,
113                           @Nullable Supplier<Locale> localeSupplier,
114                           @Nullable Supplier<List<LanguageRange>> languageRangesSupplier,
115                           @Nullable FailureMode failureMode) {
116    requireNonNull(fallbackLanguageCode);
117    requireNonNull(localizedStringSupplier);
118
119    this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName());
120
121    Map<Locale, ? extends Iterable<LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get();
122
123    if (suppliedLocalizedStringsByLocale == null)
124      suppliedLocalizedStringsByLocale = Collections.emptyMap();
125
126    // Defensive copy of iterator to unmodifiable set
127    Map<Locale, Set<LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream()
128        .collect(Collectors.toMap(
129            entry -> entry.getKey(),
130            entry -> {
131              Set<LocalizedString> localizedStrings = new LinkedHashSet<>();
132              entry.getValue().forEach(localizedStrings::add);
133              return Collections.unmodifiableSet(localizedStrings);
134            }
135        ));
136
137    this.fallbackLocale = Locale.forLanguageTag(fallbackLanguageCode);
138    this.fallbackLanguageCode = fallbackLanguageCode;
139    this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale);
140    this.languageRangesSupplier = languageRangesSupplier;
141    this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode;
142    this.stringInterpolator = new StringInterpolator();
143    this.expressionEvaluator = new ExpressionEvaluator();
144
145    this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByLocale.entrySet().stream()
146        .collect(Collectors.toMap(
147            entry1 -> entry1.getKey(),
148            entry1 ->
149                Collections.unmodifiableMap(entry1.getValue().stream()
150                    .collect(Collectors.toMap(
151                        entry2 -> entry2.getKey(),
152                        entry2 -> entry2
153                        )
154                    )))));
155
156    this.localizedStringSourcesByLocale = new ConcurrentHashMap<>();
157
158    if (!localizedStringsByLocale.containsKey(getFallbackLocale()))
159      throw new IllegalArgumentException(format("Specified fallback language code is '%s' but no matching " +
160              "localized strings locale was found. Known locales: [%s]", fallbackLanguageCode,
161          localizedStringsByLocale.keySet().stream()
162              .map(locale -> locale.toLanguageTag())
163              .sorted()
164              .collect(Collectors.joining(", "))));
165
166    if (localeSupplier != null && languageRangesSupplier != null)
167      throw new IllegalArgumentException(format("You cannot provide both a localeSupplier " +
168          "and a languageRangesSupplier when building an instance of %s - you must pick one of the two.", getClass().getSimpleName()));
169
170    if (localeSupplier == null && languageRangesSupplier == null)
171      this.localeSupplier = () -> getFallbackLocale();
172    else
173      this.localeSupplier = localeSupplier;
174  }
175
176  @Nonnull
177  @Override
178  public String get(@Nonnull String key) {
179    requireNonNull(key);
180    return get(key, null, null);
181  }
182
183  @Nonnull
184  @Override
185  public String get(@Nonnull String key, @Nullable Locale locale) {
186    requireNonNull(key);
187    return get(key, null, locale);
188  }
189
190  @Nonnull
191  @Override
192  public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders) {
193    requireNonNull(key);
194    return get(key, placeholders, null);
195  }
196
197  @Nonnull
198  @Override
199  public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders, @Nullable Locale locale) {
200    requireNonNull(key);
201
202    if (placeholders == null)
203      placeholders = Collections.emptyMap();
204
205    if (locale == null)
206      locale = getImplicitLocale();
207
208    String translation = null;
209    Map<String, Object> mutableContext = new HashMap<>(placeholders);
210    Map<String, Object> immutableContext = Collections.unmodifiableMap(placeholders);
211    List<LocalizedStringSource> localizedStringSources = getLocalizedStringSourcesForLocale(locale);
212
213    for (LocalizedStringSource localizedStringSource : localizedStringSources) {
214      LocalizedString localizedString = localizedStringSource.getLocalizedStringsByKey().get(key);
215
216      if (localizedString == null) {
217        logger.finer(format("No match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag()));
218      } else {
219        logger.finer(format("A match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag()));
220        translation = getInternal(key, localizedString, mutableContext, immutableContext, localizedStringSource.getLocale()).orElse(null);
221        break;
222      }
223    }
224
225    if (translation == null) {
226      logger.finer(format("No match for '%s' was found in any strings file.", key));
227      translation = stringInterpolator.interpolate(key, mutableContext);
228    }
229
230    return translation;
231  }
232
233  /**
234   * Recursive method which attempts to translate a localized string.
235   *
236   * @param key              the toplevel translation key (always the same regardless of recursion depth), not null
237   * @param localizedString  the localized string on which to operate, not null
238   * @param mutableContext   the mutable context for the translation, not null
239   * @param immutableContext the original user-supplied translation context, not null
240   * @param locale           the locale to use for evaluation, not null
241   * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null
242   */
243  @Nonnull
244  protected Optional<String> getInternal(@Nonnull String key, @Nonnull LocalizedString localizedString,
245                                         @Nonnull Map<String, Object> mutableContext, @Nonnull Map<String, Object> immutableContext,
246                                         @Nonnull Locale locale) {
247    requireNonNull(key);
248    requireNonNull(localizedString);
249    requireNonNull(mutableContext);
250    requireNonNull(immutableContext);
251    requireNonNull(locale);
252
253    // First, see if any alternatives match by evaluating them
254    for (LocalizedString alternative : localizedString.getAlternatives()) {
255      if (getExpressionEvaluator().evaluate(alternative.getKey(), mutableContext, locale)) {
256        logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext));
257
258        // If we have a matching alternative, recurse into it
259        return getInternal(key, alternative, mutableContext, immutableContext, locale);
260      }
261    }
262
263    if (!localizedString.getTranslation().isPresent())
264      return Optional.empty();
265
266    String translation = localizedString.getTranslation().get();
267
268    for (Entry<String, LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) {
269      String placeholderName = entry.getKey();
270      LanguageFormTranslation languageFormTranslation = entry.getValue();
271      Object value = null;
272      Object rangeStart = null;
273      Object rangeEnd = null;
274      Map<Cardinality, String> translationsByCardinality = new HashMap<>();
275      Map<Ordinality, String> translationsByOrdinality = new HashMap<>();
276      Map<Gender, String> translationsByGender = new HashMap<>();
277
278      if (languageFormTranslation.getRange().isPresent()) {
279        LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
280        rangeStart = immutableContext.get(languageFormTranslationRange.getStart());
281        rangeEnd = immutableContext.get(languageFormTranslationRange.getEnd());
282      } else {
283        value = immutableContext.get(languageFormTranslation.getValue().get());
284      }
285
286      for (Entry<LanguageForm, String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) {
287        LanguageForm languageForm = translationEntry.getKey();
288        String translatedLanguageForm = translationEntry.getValue();
289
290        if (languageForm instanceof Cardinality)
291          translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm);
292        else if (languageForm instanceof Ordinality)
293          translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm);
294        else if (languageForm instanceof Gender)
295          translationsByGender.put((Gender) languageForm, translatedLanguageForm);
296        else
297          throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm));
298      }
299
300      int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) +
301          (translationsByOrdinality.size() > 0 ? 1 : 0) +
302          (translationsByGender.size() > 0 ? 1 : 0);
303
304      if (distinctLanguageForms > 1)
305        throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString));
306
307      if (distinctLanguageForms == 0)
308        continue;
309
310      // Handle plural cardinalities
311      if (translationsByCardinality.size() > 0) {
312        // Special case: calculate range from min and max if this is a range-driven cardinality
313        if (languageFormTranslation.getRange().isPresent()) {
314          if (rangeStart == null)
315            rangeStart = 0;
316          if (rangeEnd == null)
317            rangeEnd = 0;
318
319          if (!(rangeStart instanceof Number)) {
320            logger.warning(format("Range start '%s' for '%s' is not a number, falling back to 0.",
321                rangeStart, languageFormTranslation.getValue()));
322            rangeStart = 0;
323          }
324
325          if (!(rangeEnd instanceof Number)) {
326            logger.warning(format("Range value end '%s' for '%s' is not a number, falling back to 0.",
327                rangeEnd, languageFormTranslation.getValue()));
328            rangeEnd = 0;
329          }
330
331          Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale);
332          Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale);
333          Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale);
334
335          String cardinalityTranslation = translationsByCardinality.get(rangeCardinality);
336
337          if (cardinalityTranslation == null)
338            logger.warning(format("Unable to find %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s",
339                Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString));
340
341          mutableContext.put(placeholderName, cardinalityTranslation);
342        } else {
343          // Normal "non-range" cardinality
344          if (value == null)
345            value = 0;
346
347          if (!(value instanceof Number)) {
348            logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
349                value, languageFormTranslation.getValue()));
350            value = 0;
351          }
352
353          Cardinality cardinality = Cardinality.forNumber((Number) value, locale);
354          String cardinalityTranslation = translationsByCardinality.get(cardinality);
355
356          if (cardinalityTranslation == null)
357            logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
358                Cardinality.class.getSimpleName(), cardinality.name(), localizedString));
359
360          mutableContext.put(placeholderName, cardinalityTranslation);
361        }
362      }
363
364      // Handle plural ordinalities
365      if (translationsByOrdinality.size() > 0) {
366        if (value == null)
367          value = 0;
368
369        if (!(value instanceof Number)) {
370          logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
371              value, languageFormTranslation.getValue()));
372          value = 0;
373        }
374
375        Ordinality ordinality = Ordinality.forNumber((Number) value, locale);
376        String ordinalityTranslation = translationsByOrdinality.get(ordinality);
377
378        if (ordinalityTranslation == null)
379          logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
380              Ordinality.class.getSimpleName(), ordinality.name(), localizedString));
381
382        mutableContext.put(placeholderName, ordinalityTranslation);
383      }
384
385      // Handle genders
386      if (translationsByGender.size() > 0) {
387        if (value == null) {
388          logger.warning(format("Value '%s' for '%s' is null. No replacement will be performed.", value,
389              languageFormTranslation.getValue()));
390          continue;
391        }
392
393        if (!(value instanceof Gender)) {
394          logger.warning(format("Value '%s' for '%s' is not a %s. No replacement will be performed.", value,
395              languageFormTranslation.getValue(), Gender.class.getSimpleName()));
396          continue;
397        }
398
399        Gender gender = (Gender) value;
400        String genderTranslation = translationsByGender.get(gender);
401
402        if (genderTranslation == null)
403          logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
404              Gender.class.getSimpleName(), gender.name(), localizedString));
405
406        mutableContext.put(placeholderName, genderTranslation);
407      }
408    }
409
410    translation = stringInterpolator.interpolate(translation, mutableContext);
411
412    return Optional.of(translation);
413  }
414
415  @Nonnull
416  protected List<LocalizedStringSource> getLocalizedStringSourcesForLocale(@Nonnull Locale locale) {
417    requireNonNull(locale);
418
419    return getLocalizedStringSourcesByLocale().computeIfAbsent(locale, (ignored) -> {
420      String language = LocaleUtils.normalizedLanguage(locale).orElse(null);
421      String script = locale.getScript();
422      String country = locale.getCountry();
423      String variant = locale.getVariant();
424      Set<Character> extensionKeys = locale.hasExtensions() ? locale.getExtensionKeys() : Collections.emptySet();
425      Set<LocalizedString> localizedStrings;
426      Set<Locale> matchingLocales = new HashSet<>(5);
427      List<LocalizedStringSource> localizedStringSources = new ArrayList<>(5);
428
429      if (logger.isLoggable(Level.FINER))
430        logger.finer(format("Finding strings files that match locale '%s'...", locale.toLanguageTag()));
431
432      // Try most specific (matches all 5 criteria) and move back to least specific
433      Locale.Builder extensionsLocaleBuilder =
434          new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant);
435
436      for (Character extensionKey : extensionKeys)
437        extensionsLocaleBuilder.setExtension(extensionKey, locale.getExtension(extensionKey));
438
439      Locale extensionsLocale = extensionsLocaleBuilder.build();
440      matchingLocales.add(extensionsLocale);
441      localizedStrings = getLocalizedStringsByLocale().get(extensionsLocale);
442
443      if (localizedStrings != null) {
444        localizedStringSources.add(new LocalizedStringSource(extensionsLocale, getLocalizedStringsByKeyByLocale().get(extensionsLocale)));
445
446        if (logger.isLoggable(Level.FINER))
447          logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
448              extensionsLocale.toLanguageTag()));
449      }
450
451      // Variant (4)
452      Locale variantLocale =
453          new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant)
454              .build();
455
456      if (!matchingLocales.contains(variantLocale)) {
457        matchingLocales.add(variantLocale);
458
459        localizedStrings = getLocalizedStringsByLocale().get(variantLocale);
460
461        if (localizedStrings != null) {
462          localizedStringSources.add(new LocalizedStringSource(variantLocale, getLocalizedStringsByKeyByLocale().get(variantLocale)));
463
464          if (logger.isLoggable(Level.FINER))
465            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
466                variantLocale.toLanguageTag()));
467        }
468      }
469
470      // Region (3)
471      Locale regionLocale = new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).build();
472
473      if (!matchingLocales.contains(regionLocale)) {
474        matchingLocales.add(regionLocale);
475
476        localizedStrings = getLocalizedStringsByLocale().get(regionLocale);
477
478        if (localizedStrings != null) {
479          localizedStringSources.add(new LocalizedStringSource(regionLocale, getLocalizedStringsByKeyByLocale().get(regionLocale)));
480
481          if (logger.isLoggable(Level.FINER))
482            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
483                regionLocale.toLanguageTag()));
484        }
485      }
486
487      // Script (2)
488      Locale scriptLocale = new Locale.Builder().setLanguage(language).setScript(script).build();
489
490      if (!matchingLocales.contains(scriptLocale)) {
491        matchingLocales.add(scriptLocale);
492
493        localizedStrings = getLocalizedStringsByLocale().get(scriptLocale);
494
495        if (localizedStrings != null) {
496          localizedStringSources.add(new LocalizedStringSource(scriptLocale, getLocalizedStringsByKeyByLocale().get(scriptLocale)));
497
498          if (logger.isLoggable(Level.FINER))
499            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
500                scriptLocale.toLanguageTag()));
501        }
502      }
503
504      // Language (1)
505      Locale languageLocale = new Locale.Builder().setLanguage(language).build();
506
507      if (!matchingLocales.contains(languageLocale)) {
508        matchingLocales.add(languageLocale);
509
510        localizedStrings = getLocalizedStringsByLocale().get(languageLocale);
511
512        if (localizedStrings != null) {
513          localizedStringSources.add(new LocalizedStringSource(languageLocale, getLocalizedStringsByKeyByLocale().get(languageLocale)));
514
515          if (logger.isLoggable(Level.FINER))
516            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
517                languageLocale.toLanguageTag()));
518        }
519      }
520
521      // Finally, add the default locale if necessary
522      Locale fallbackLocale = getFallbackLocale();
523
524      if (!matchingLocales.contains(fallbackLocale)) {
525        matchingLocales.add(fallbackLocale);
526
527        localizedStrings = getLocalizedStringsByLocale().get(fallbackLocale);
528
529        if (localizedStrings != null) {
530          localizedStringSources.add(new LocalizedStringSource(fallbackLocale, getLocalizedStringsByKeyByLocale().get(fallbackLocale)));
531
532          if (logger.isLoggable(Level.FINER))
533            logger.finer(format("A matching strings file for locale '%s' is fallback '%s'",
534                locale.toLanguageTag(), fallbackLocale.toLanguageTag()));
535        }
536      }
537
538      return Collections.unmodifiableList(localizedStringSources);
539    });
540  }
541
542  /**
543   * Gets the fallback language code.
544   *
545   * @return the fallback language code, not null
546   */
547  @Nonnull
548  public String getFallbackLanguageCode() {
549    return fallbackLanguageCode;
550  }
551
552  /**
553   * Gets the set of localized strings for each locale.
554   *
555   * @return the set of localized strings for each locale, not null
556   */
557  @Nonnull
558  public Map<Locale, Set<LocalizedString>> getLocalizedStringsByLocale() {
559    return localizedStringsByLocale;
560  }
561
562  /**
563   * Gets the locale supplier.
564   *
565   * @return the locale supplier, not null
566   */
567  @Nonnull
568  protected Optional<Supplier<Locale>> getLocaleSupplier() {
569    return Optional.ofNullable(localeSupplier);
570  }
571
572  /**
573   * Gets the language ranges supplier.
574   *
575   * @return the language ranges supplier, not null
576   */
577  @Nonnull
578  protected Optional<Supplier<List<LanguageRange>>> getLanguageRangesSupplier() {
579    return Optional.ofNullable(languageRangesSupplier);
580  }
581
582  /**
583   * Gets the strategy for handling string lookup failures.
584   *
585   * @return the strategy for handling string lookup failures, not null
586   */
587  @Nonnull
588  public FailureMode getFailureMode() {
589    return failureMode;
590  }
591
592  /**
593   * Gets the fallback locale.
594   *
595   * @return the fallback locale, not null
596   */
597  @Nonnull
598  protected Locale getFallbackLocale() {
599    return fallbackLocale;
600  }
601
602  /**
603   * Gets the locale to use if one was not explicitly provided.
604   *
605   * @return the implicit locale to use, not null
606   */
607  @Nonnull
608  protected Locale getImplicitLocale() {
609    Locale locale = null;
610
611    if (getLocaleSupplier().isPresent()) {
612      locale = getLocaleSupplier().get().get();
613    } else if (getLanguageRangesSupplier().isPresent()) {
614      List<LanguageRange> languageRanges = getLanguageRangesSupplier().get().get();
615
616      if (languageRanges != null)
617        locale = Locale.lookup(languageRanges, getLocalizedStringsByLocale().keySet());
618    }
619
620    return locale == null ? getFallbackLocale() : locale;
621  }
622
623  /**
624   * Gets the string interpolator used to merge placeholders into translations.
625   *
626   * @return the string interpolator, not null
627   */
628  @Nonnull
629  protected StringInterpolator getStringInterpolator() {
630    return stringInterpolator;
631  }
632
633  /**
634   * Gets the expression evaluator used to determine if alternative expressions match the evaluation context.
635   *
636   * @return the expression evaluator, not null
637   */
638  @Nonnull
639  protected ExpressionEvaluator getExpressionEvaluator() {
640    return expressionEvaluator;
641  }
642
643  /**
644   * Gets our "master" cache of localized strings by key by locale.
645   *
646   * @return the cache of localized strings by key by locale, not null
647   */
648  @Nonnull
649  protected Map<Locale, Map<String, LocalizedString>> getLocalizedStringsByKeyByLocale() {
650    return localizedStringsByKeyByLocale;
651  }
652
653  /**
654   * Get the "runtime" generated map of locales to localized string sources.
655   *
656   * @return the map of locales to localized string sources, not null
657   */
658  @Nonnull
659  protected ConcurrentHashMap<Locale, List<LocalizedStringSource>> getLocalizedStringSourcesByLocale() {
660    return localizedStringSourcesByLocale;
661  }
662
663  /**
664   * Data structure which holds a locale and the localized strings for it, with the strings mapped by key for fast access.
665   *
666   * @author <a href="https://revetkn.com">Mark Allen</a>
667   */
668  @Immutable
669  static class LocalizedStringSource {
670    @Nonnull
671    private final Locale locale;
672    @Nonnull
673    private final Map<String, LocalizedString> localizedStringsByKey;
674
675    /**
676     * Constructs a localized string source with the given locale and map of keys to localized strings.
677     *
678     * @param locale                the locale for these localized strings, not null
679     * @param localizedStringsByKey localized strings by translation key, not null
680     */
681    public LocalizedStringSource(@Nonnull Locale locale, @Nonnull Map<String, LocalizedString> localizedStringsByKey) {
682      requireNonNull(locale);
683      requireNonNull(localizedStringsByKey);
684
685      this.locale = locale;
686      this.localizedStringsByKey = localizedStringsByKey;
687    }
688
689    /**
690     * Generates a {@code String} representation of this object.
691     *
692     * @return a string representation of this object, not null
693     */
694    @Override
695    @Nonnull
696    public String toString() {
697      return format("%s{locale=%s, localizedStringsByKey=%s", getClass().getSimpleName(), getLocale(), getLocalizedStringsByKey());
698    }
699
700    /**
701     * Checks if this object is equal to another one.
702     *
703     * @param other the object to check, null returns false
704     * @return true if this is equal to the other object, false otherwise
705     */
706    @Override
707    public boolean equals(@Nullable Object other) {
708      if (this == other)
709        return true;
710
711      if (other == null || !getClass().equals(other.getClass()))
712        return false;
713
714      LocalizedStringSource localizedStringSource = (LocalizedStringSource) other;
715
716      return Objects.equals(getLocale(), localizedStringSource.getLocale())
717          && Objects.equals(getLocalizedStringsByKey(), localizedStringSource.getLocalizedStringsByKey());
718    }
719
720    /**
721     * A hash code for this object.
722     *
723     * @return a suitable hash code
724     */
725    @Override
726    public int hashCode() {
727      return Objects.hash(getLocale(), getLocalizedStringsByKey());
728    }
729
730    @Nonnull
731    public Locale getLocale() {
732      return locale;
733    }
734
735    @Nonnull
736    public Map<String, LocalizedString> getLocalizedStringsByKey() {
737      return localizedStringsByKey;
738    }
739  }
740
741  /**
742   * Strategies for handling localized string lookup failures.
743   */
744  public enum FailureMode {
745    /**
746     * The system will attempt a series of fallbacks in order to not throw an exception at runtime.
747     * <p>
748     * This mode is useful for production, where we often want program execution to continue in the face of
749     * localization errors.
750     */
751    USE_FALLBACK,
752    /**
753     * The system will throw an exception if a localization is missing for the specified locale.
754     * <p>
755     * This mode is useful for testing, since problems are uncovered right away when execution halts.
756     */
757    FAIL_FAST
758  }
759
760  /**
761   * Builder used to construct instances of {@link DefaultStrings}.
762   * <p>
763   * You cannot provide both a {@code localeSupplier} and a {@code languageRangesSupplier} - you must choose one or neither.
764   * <p>
765   * This class is intended for use by a single thread.
766   *
767   * @author <a href="https://revetkn.com">Mark Allen</a>
768   */
769  @NotThreadSafe
770  public static class Builder {
771    @Nonnull
772    private final String fallbackLanguageCode;
773    @Nonnull
774    private final Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier;
775    @Nullable
776    private Supplier<Locale> localeSupplier;
777    @Nullable
778    private Supplier<List<LanguageRange>> languageRangesSupplier;
779    @Nullable
780    private FailureMode failureMode;
781
782    /**
783     * Constructs a strings builder with a default language code and localized string supplier.
784     * <p>
785     * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code.
786     * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used.
787     *
788     * @param fallbackLanguageCode    fallback language code, not null
789     * @param localizedStringSupplier supplier of localized strings, not null
790     */
791    public Builder(@Nonnull String fallbackLanguageCode, @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier) {
792      requireNonNull(fallbackLanguageCode);
793      requireNonNull(localizedStringSupplier);
794
795      this.fallbackLanguageCode = fallbackLanguageCode;
796      this.localizedStringSupplier = localizedStringSupplier;
797    }
798
799    /**
800     * Applies a locale supplier to this builder.
801     *
802     * @param localeSupplier locale supplier, may be null
803     * @return this builder instance, useful for chaining. not null
804     */
805    @Nonnull
806    public Builder localeSupplier(@Nullable Supplier<Locale> localeSupplier) {
807      this.localeSupplier = localeSupplier;
808      return this;
809    }
810
811    /**
812     * Applies a supplier of language ranges to this builder.
813     *
814     * @param languageRangesSupplier language ranges supplier, may be null
815     * @return this builder instance, useful for chaining. not null
816     */
817    @Nonnull
818    public Builder languageRangesSupplier(@Nullable Supplier<List<LanguageRange>> languageRangesSupplier) {
819      this.languageRangesSupplier = languageRangesSupplier;
820      return this;
821    }
822
823    /**
824     * Applies a failure mode to this builder.
825     *
826     * @param failureMode strategy for dealing with lookup failures, may be null
827     * @return this builder instance, useful for chaining. not null
828     */
829    @Nonnull
830    public Builder failureMode(@Nullable FailureMode failureMode) {
831      this.failureMode = failureMode;
832      return this;
833    }
834
835    /**
836     * Constructs an instance of {@link DefaultStrings}.
837     *
838     * @return an instance of {@link DefaultStrings}, not null
839     */
840    @Nonnull
841    public DefaultStrings build() {
842      return new DefaultStrings(fallbackLanguageCode, localizedStringSupplier, localeSupplier, languageRangesSupplier, failureMode);
843    }
844  }
845}