001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2025 Revetware 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.Comparator;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.LinkedHashSet;
033import java.util.List;
034import java.util.Locale;
035import java.util.Locale.LanguageRange;
036import java.util.Map;
037import java.util.Map.Entry;
038import java.util.Objects;
039import java.util.Optional;
040import java.util.Set;
041import java.util.function.Function;
042import java.util.function.Supplier;
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 the current request, e.g.
056 * from a set of {@link LanguageRange} parsed from the {@code Accept-Language} header.
057 *
058 * @author <a href="https://revetkn.com">Mark Allen</a>
059 */
060@ThreadSafe
061public class DefaultStrings implements Strings {
062        @Nonnull
063        private final Map<Locale, Set<LocalizedString>> localizedStringsByLocale;
064        @Nullable
065        private final Function<LocaleMatcher, Locale> localeSupplier;
066        @Nonnull
067        private final Map<String, List<Locale>> tiebreakerLocalesByLanguageCode;
068        @Nonnull
069        private final FailureMode failureMode;
070        @Nonnull
071        private final Locale fallbackLocale;
072        @Nonnull
073        private final StringInterpolator stringInterpolator;
074        @Nonnull
075        private final ExpressionEvaluator expressionEvaluator;
076        @Nonnull
077        private final Logger logger;
078
079        /**
080         * Cache of localized strings by key by locale.
081         * <p>
082         * This is our "master" reference localized string storage that other data structures will point to.
083         */
084        @Nonnull
085        private final Map<Locale, Map<String, LocalizedString>> localizedStringsByKeyByLocale;
086
087        /**
088         * Vends a builder suitable for constructing {@link DefaultStrings) instances.
089         * <p>
090         * This method is package-private and designed to be invoked via {@link Strings#withFallbackLocale(Locale)}.
091         *
092         * @param fallbackLocale the fallback locale used if no others match, not null
093         * @return the builder, not null
094         */
095        @Nonnull
096        static Builder withFallbackLocale(@Nonnull Locale fallbackLocale) {
097                requireNonNull(fallbackLocale);
098                return new Builder(fallbackLocale);
099        }
100
101        /**
102         * Constructs a localized string provider with builder-supplied data.
103         *
104         * @param fallbackLocale                  fallback locale, not null
105         * @param localizedStringSupplier         supplier of localized strings, not null
106         * @param localeSupplier                  locale supplier, may not be null
107         * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null
108         * @param failureMode                     strategy for dealing with lookup failures, may be null
109         */
110        protected DefaultStrings(@Nonnull Locale fallbackLocale,
111                                                                                                         @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier,
112                                                                                                         @Nonnull Function<LocaleMatcher, Locale> localeSupplier,
113                                                                                                         @Nullable Map<String, List<Locale>> tiebreakerLocalesByLanguageCode,
114                                                                                                         @Nullable FailureMode failureMode) {
115                requireNonNull(fallbackLocale);
116                requireNonNull(localizedStringSupplier, format("You must specify a 'localizedStringSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
117                requireNonNull(localeSupplier, format("You must specify a 'localeSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
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 = fallbackLocale;
138                this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale);
139
140                // Make our own mapping of tiebreakers based on the provided mapping.
141                // First, defensive copy, then add to the map as needed below.
142                Map<String, List<Locale>> internalTiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode == null ? new HashMap<>() : new HashMap<>(tiebreakerLocalesByLanguageCode);
143
144                // Verify tiebreakers are provided to support locale resolution when ambiguity exists.
145                // For each language code, if there is more than 1 localized strings file that matches it, tiebreakers must be provided.
146                Map<String, Set<Locale>> supportedLocalesByLanguageCode = new HashMap<>(localizedStringsByLocale.size());
147
148                for (Locale supportedLocale : localizedStringsByLocale.keySet()) {
149                        String languageCode = supportedLocale.getLanguage();
150                        Set<Locale> locales = supportedLocalesByLanguageCode.get(languageCode);
151
152                        if (locales == null) {
153                                locales = new HashSet<>();
154                                supportedLocalesByLanguageCode.put(languageCode, locales);
155                        }
156
157                        locales.add(supportedLocale);
158                }
159
160                for (Entry<String, Set<Locale>> entry : supportedLocalesByLanguageCode.entrySet()) {
161                        String languageCode = entry.getKey();
162                        List<Locale> locales = entry.getValue().stream()
163                                        .sorted(Comparator.comparing(Locale::toLanguageTag))
164                                        .collect(Collectors.toList());
165
166                        if (locales.size() == 1) {
167                                // If there is exactly 1 locale for the language code, it's its own "identity" tiebreaker.
168                                internalTiebreakerLocalesByLanguageCode.put(languageCode, locales);
169                        } else if (locales.size() > 1) {
170                                // We need to provide tiebreakers if a locale has more than 1 strings file.
171                                List<Locale> providedTiebreakerLocales = internalTiebreakerLocalesByLanguageCode.get(languageCode);
172
173                                if (providedTiebreakerLocales == null || providedTiebreakerLocales.size() == 0) {
174                                        throw new IllegalArgumentException(format("You must specify tiebreaker locales via 'tiebreakerLocalesByLanguageCode' to resolve ambiguity for language code '%s' because localized strings exist for the following locale[s]: %s",
175                                                        languageCode, locales.stream().map(locale -> locale.toLanguageTag()).collect(Collectors.toList())));
176                                } else {
177                                        // First, verify that all tiebreakers actually exist
178                                        Set<Locale> supportedLocales = localizedStringsByLocale.keySet();
179
180                                        for (Locale providedTiebreakerLocale : providedTiebreakerLocales)
181                                                if (!supportedLocales.contains(providedTiebreakerLocale))
182                                                        throw new IllegalArgumentException(format("Tiebreaker locale '%s' specified in 'tiebreakerLocalesByLanguageCode' does not have a localized strings file. Supported locales are: %s",
183                                                                        providedTiebreakerLocale.toLanguageTag(), supportedLocales.stream().map(supportedLocale -> supportedLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
184
185                                        // Next, verify that tiebreakers are exhaustively specified
186                                        List<Locale> missingLocales = new ArrayList<>(locales.size());
187
188                                        for (Locale locale : locales)
189                                                if (!providedTiebreakerLocales.contains(locale))
190                                                        missingLocales.add(locale);
191
192                                        if (missingLocales.size() > 0)
193                                                throw new IllegalArgumentException(format("Your 'tiebreakerLocalesByLanguageCode' specifies locale[s] %s for language code '%s', but you are missing entries for the following locale[s]: %s",
194                                                                providedTiebreakerLocales.stream().map(providedTiebreakerLocale -> providedTiebreakerLocale.toLanguageTag()).sorted().collect(Collectors.toList()),
195                                                                languageCode,
196                                                                missingLocales.stream().map(missingLocale -> missingLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
197                                }
198
199                                internalTiebreakerLocalesByLanguageCode.put(languageCode, locales);
200                        } else {
201                                // Should never occur
202                                throw new IllegalStateException("No locales match language code");
203                        }
204                }
205
206                this.tiebreakerLocalesByLanguageCode = Collections.unmodifiableMap(internalTiebreakerLocalesByLanguageCode);
207
208                this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode;
209                this.stringInterpolator = new StringInterpolator();
210                this.expressionEvaluator = new ExpressionEvaluator();
211
212                this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByLocale.entrySet().stream()
213                                .collect(Collectors.toMap(
214                                                entry1 -> entry1.getKey(),
215                                                entry1 ->
216                                                                Collections.unmodifiableMap(entry1.getValue().stream()
217                                                                                .collect(Collectors.toMap(
218                                                                                                                entry2 -> entry2.getKey(),
219                                                                                                                entry2 -> entry2
220                                                                                                )
221                                                                                )))));
222
223                if (!localizedStringsByLocale.containsKey(getFallbackLocale()))
224                        throw new IllegalArgumentException(format("Specified fallback locale is '%s' but no matching " +
225                                                        "localized strings locale was found. Known locales: [%s]", fallbackLocale.toLanguageTag(),
226                                        localizedStringsByLocale.keySet().stream()
227                                                        .map(locale -> locale.toLanguageTag())
228                                                        .sorted()
229                                                        .collect(Collectors.joining(", "))));
230
231                this.localeSupplier = localeSupplier;
232        }
233
234        @Nonnull
235        @Override
236        public String get(@Nonnull String key) {
237                requireNonNull(key);
238                return get(key, null, getLocaleSupplier().apply(this));
239        }
240
241        @Nonnull
242        @Override
243        public String get(@Nonnull String key,
244                                                                                @Nullable Map<String, Object> placeholders) {
245                requireNonNull(key);
246                return get(key, placeholders, getLocaleSupplier().apply(this));
247        }
248
249        @Nonnull
250        protected String get(@Nonnull String key,
251                                                                                         @Nullable Map<String, Object> placeholders,
252                                                                                         @Nonnull Locale locale) {
253                requireNonNull(key);
254                requireNonNull(locale);
255
256                if (placeholders == null)
257                        placeholders = Collections.emptyMap();
258
259                Locale finalLocale = locale;
260                Map<String, Object> mutableContext = new HashMap<>(placeholders);
261                Map<String, Object> immutableContext = Collections.unmodifiableMap(placeholders);
262
263                Map<String, LocalizedString> localizedStrings = getLocalizedStringsByKeyByLocale().get(locale);
264
265                if (localizedStrings == null) {
266                        finalLocale = getFallbackLocale();
267                        localizedStrings = getLocalizedStringsByKeyByLocale().get(getFallbackLocale());
268                }
269
270                // Should never occur
271                if (localizedStrings == null)
272                        throw new IllegalStateException(format("Unable to find strings file for both '%s' and fallback locale '%s'",
273                                        locale.toLanguageTag(), getFallbackLocale().toLanguageTag()));
274
275                LocalizedString localizedString = localizedStrings.get(key);
276                String translation = null;
277
278                if (localizedString != null)
279                        translation = getInternal(key, localizedString, mutableContext, immutableContext, finalLocale).orElse(null);
280
281                if (translation == null) {
282                        String message = format("No match for '%s' was found for locale '%s'.", key, locale.toLanguageTag());
283                        logger.finer(message);
284
285                        if (getFailureMode() == FailureMode.FAIL_FAST)
286                                throw new MissingTranslationException(message, key, placeholders, locale);
287
288                        // Not fail-fast?  Merge against the key itself in hopes that the key is a meaningful natural-language value
289                        translation = getStringInterpolator().interpolate(key, mutableContext);
290                }
291
292                return translation;
293        }
294
295        /**
296         * Recursive method which attempts to translate a localized string.
297         *
298         * @param key              the toplevel translation key (always the same regardless of recursion depth), not null
299         * @param localizedString  the localized string on which to operate, not null
300         * @param mutableContext   the mutable context for the translation, not null
301         * @param immutableContext the original user-supplied translation context, not null
302         * @param locale           the locale to use for evaluation, not null
303         * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null
304         */
305        @Nonnull
306        protected Optional<String> getInternal(@Nonnull String key, @Nonnull LocalizedString localizedString,
307                                                                                                                                                                 @Nonnull Map<String, Object> mutableContext, @Nonnull Map<String, Object> immutableContext,
308                                                                                                                                                                 @Nonnull Locale locale) {
309                requireNonNull(key);
310                requireNonNull(localizedString);
311                requireNonNull(mutableContext);
312                requireNonNull(immutableContext);
313                requireNonNull(locale);
314
315                // First, see if any alternatives match by evaluating them
316                for (LocalizedString alternative : localizedString.getAlternatives()) {
317                        if (getExpressionEvaluator().evaluate(alternative.getKey(), mutableContext, locale)) {
318                                logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext));
319
320                                // If we have a matching alternative, recurse into it
321                                return getInternal(key, alternative, mutableContext, immutableContext, locale);
322                        }
323                }
324
325                if (!localizedString.getTranslation().isPresent())
326                        return Optional.empty();
327
328                String translation = localizedString.getTranslation().get();
329
330                for (Entry<String, LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) {
331                        String placeholderName = entry.getKey();
332                        LanguageFormTranslation languageFormTranslation = entry.getValue();
333                        Object value = null;
334                        Object rangeStart = null;
335                        Object rangeEnd = null;
336                        Map<Cardinality, String> translationsByCardinality = new HashMap<>();
337                        Map<Ordinality, String> translationsByOrdinality = new HashMap<>();
338                        Map<Gender, String> translationsByGender = new HashMap<>();
339
340                        if (languageFormTranslation.getRange().isPresent()) {
341                                LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
342                                rangeStart = immutableContext.get(languageFormTranslationRange.getStart());
343                                rangeEnd = immutableContext.get(languageFormTranslationRange.getEnd());
344                        } else {
345                                value = immutableContext.get(languageFormTranslation.getValue().get());
346                        }
347
348                        for (Entry<LanguageForm, String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) {
349                                LanguageForm languageForm = translationEntry.getKey();
350                                String translatedLanguageForm = translationEntry.getValue();
351
352                                if (languageForm instanceof Cardinality)
353                                        translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm);
354                                else if (languageForm instanceof Ordinality)
355                                        translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm);
356                                else if (languageForm instanceof Gender)
357                                        translationsByGender.put((Gender) languageForm, translatedLanguageForm);
358                                else
359                                        throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm));
360                        }
361
362                        int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) +
363                                        (translationsByOrdinality.size() > 0 ? 1 : 0) +
364                                        (translationsByGender.size() > 0 ? 1 : 0);
365
366                        if (distinctLanguageForms > 1)
367                                throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString));
368
369                        if (distinctLanguageForms == 0)
370                                continue;
371
372                        // Handle plural cardinalities
373                        if (translationsByCardinality.size() > 0) {
374                                // Special case: calculate range from min and max if this is a range-driven cardinality
375                                if (languageFormTranslation.getRange().isPresent()) {
376                                        if (rangeStart == null)
377                                                rangeStart = 0;
378                                        if (rangeEnd == null)
379                                                rangeEnd = 0;
380
381                                        if (!(rangeStart instanceof Number)) {
382                                                logger.warning(format("Range start '%s' for '%s' is not a number, falling back to 0.",
383                                                                rangeStart, languageFormTranslation.getValue()));
384                                                rangeStart = 0;
385                                        }
386
387                                        if (!(rangeEnd instanceof Number)) {
388                                                logger.warning(format("Range value end '%s' for '%s' is not a number, falling back to 0.",
389                                                                rangeEnd, languageFormTranslation.getValue()));
390                                                rangeEnd = 0;
391                                        }
392
393                                        Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale);
394                                        Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale);
395                                        Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale);
396
397                                        String cardinalityTranslation = translationsByCardinality.get(rangeCardinality);
398
399                                        if (cardinalityTranslation == null)
400                                                logger.warning(format("Unable to find %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s",
401                                                                Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString));
402
403                                        mutableContext.put(placeholderName, cardinalityTranslation);
404                                } else {
405                                        // Normal "non-range" cardinality
406                                        if (value == null)
407                                                value = 0;
408
409                                        if (!(value instanceof Number)) {
410                                                logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
411                                                                value, languageFormTranslation.getValue()));
412                                                value = 0;
413                                        }
414
415                                        Cardinality cardinality = Cardinality.forNumber((Number) value, locale);
416                                        String cardinalityTranslation = translationsByCardinality.get(cardinality);
417
418                                        if (cardinalityTranslation == null)
419                                                logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
420                                                                Cardinality.class.getSimpleName(), cardinality.name(), localizedString));
421
422                                        mutableContext.put(placeholderName, cardinalityTranslation);
423                                }
424                        }
425
426                        // Handle plural ordinalities
427                        if (translationsByOrdinality.size() > 0) {
428                                if (value == null)
429                                        value = 0;
430
431                                if (!(value instanceof Number)) {
432                                        logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
433                                                        value, languageFormTranslation.getValue()));
434                                        value = 0;
435                                }
436
437                                Ordinality ordinality = Ordinality.forNumber((Number) value, locale);
438                                String ordinalityTranslation = translationsByOrdinality.get(ordinality);
439
440                                if (ordinalityTranslation == null)
441                                        logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
442                                                        Ordinality.class.getSimpleName(), ordinality.name(), localizedString));
443
444                                mutableContext.put(placeholderName, ordinalityTranslation);
445                        }
446
447                        // Handle genders
448                        if (translationsByGender.size() > 0) {
449                                if (value == null) {
450                                        logger.warning(format("Value '%s' for '%s' is null. No replacement will be performed.", value,
451                                                        languageFormTranslation.getValue()));
452                                        continue;
453                                }
454
455                                if (!(value instanceof Gender)) {
456                                        logger.warning(format("Value '%s' for '%s' is not a %s. No replacement will be performed.", value,
457                                                        languageFormTranslation.getValue(), Gender.class.getSimpleName()));
458                                        continue;
459                                }
460
461                                Gender gender = (Gender) value;
462                                String genderTranslation = translationsByGender.get(gender);
463
464                                if (genderTranslation == null)
465                                        logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
466                                                        Gender.class.getSimpleName(), gender.name(), localizedString));
467
468                                mutableContext.put(placeholderName, genderTranslation);
469                        }
470                }
471
472                translation = getStringInterpolator().interpolate(translation, mutableContext);
473
474                return Optional.of(translation);
475        }
476
477        @Nonnull
478        @Override
479        public Locale bestMatchFor(@Nonnull Locale locale) {
480                requireNonNull(locale);
481                return bestMatchFor(List.of(new LanguageRange(locale.toLanguageTag())));
482        }
483
484        @Nonnull
485        @Override
486        public Locale bestMatchFor(@Nonnull List<LanguageRange> languageRanges) {
487                requireNonNull(languageRanges);
488
489                if (languageRanges.isEmpty())
490                        return getFallbackLocale();
491
492                // Walk through each LanguageRange in preference order
493                for (LanguageRange languageRange : languageRanges) {
494                        String range = languageRange.getRange(); // e.g. "pt" or "pt-PT"
495                        double weight = languageRange.getWeight();
496
497                        if (weight <= 0)
498                                continue;
499
500                        // Exact tag match?
501                        for (Locale locale : getLocalizedStringsByLocale().keySet())
502                                if (locale.toLanguageTag().equalsIgnoreCase(range))
503                                        return locale;
504
505                        // Primary‐tag match (e.g. range="pt" or "pt-XX")
506                        String primary = range.split("-")[0]; // e.g. "pt"
507                        List<Locale> candidates = getLocalizedStringsByLocale().keySet().stream()
508                                        .filter(locale -> locale.getLanguage().equalsIgnoreCase(primary))
509                                        .collect(Collectors.toList());
510
511                        if (candidates.isEmpty())
512                                continue; // try the next LanguageRange
513
514                        if (candidates.size() == 1)
515                                return candidates.get(0);
516
517                        // Tie‐breaker list for this primary tag?
518                        List<Locale> tiebreakers = getTiebreakerLocalesByLanguageCode().get(primary);
519
520                        if (tiebreakers != null)
521                                for (Locale tiebreaker : tiebreakers)
522                                        if (candidates.contains(tiebreaker))
523                                                return tiebreaker;
524                }
525
526                // 4) Nothing matched at all
527                return getFallbackLocale();
528        }
529
530        /**
531         * Gets the set of localized strings for each locale.
532         *
533         * @return the set of localized strings for each locale, not null
534         */
535        @Nonnull
536        public Map<Locale, Set<LocalizedString>> getLocalizedStringsByLocale() {
537                return localizedStringsByLocale;
538        }
539
540        /**
541         * Gets the locale supplier.
542         *
543         * @return the locale supplier, not null
544         */
545        @Nonnull
546        public Function<LocaleMatcher, Locale> getLocaleSupplier() {
547                return this.localeSupplier;
548        }
549
550        /**
551         * Gets the mapping of a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales.
552         *
553         * @return the per-language-code "tiebreaker" locales, not null
554         */
555        @Nonnull
556        public Map<String, List<Locale>> getTiebreakerLocalesByLanguageCode() {
557                return this.tiebreakerLocalesByLanguageCode;
558        }
559
560        /**
561         * Gets the strategy for handling string lookup failures.
562         *
563         * @return the strategy for handling string lookup failures, not null
564         */
565        @Nonnull
566        public FailureMode getFailureMode() {
567                return failureMode;
568        }
569
570        /**
571         * Gets the fallback locale.
572         *
573         * @return the fallback locale, not null
574         */
575        @Nonnull
576        public Locale getFallbackLocale() {
577                return fallbackLocale;
578        }
579
580        /**
581         * Gets the string interpolator used to merge placeholders into translations.
582         *
583         * @return the string interpolator, not null
584         */
585        @Nonnull
586        protected StringInterpolator getStringInterpolator() {
587                return stringInterpolator;
588        }
589
590        /**
591         * Gets the expression evaluator used to determine if alternative expressions match the evaluation context.
592         *
593         * @return the expression evaluator, not null
594         */
595        @Nonnull
596        protected ExpressionEvaluator getExpressionEvaluator() {
597                return expressionEvaluator;
598        }
599
600        /**
601         * Gets our "master" cache of localized strings by key by locale.
602         *
603         * @return the cache of localized strings by key by locale, not null
604         */
605        @Nonnull
606        protected Map<Locale, Map<String, LocalizedString>> getLocalizedStringsByKeyByLocale() {
607                return localizedStringsByKeyByLocale;
608        }
609
610        /**
611         * Data structure which holds a locale and the localized strings for it, with the strings mapped by key for fast access.
612         *
613         * @author <a href="https://revetkn.com">Mark Allen</a>
614         */
615        @Immutable
616        static class LocalizedStringSource {
617                @Nonnull
618                private final Locale locale;
619                @Nonnull
620                private final Map<String, LocalizedString> localizedStringsByKey;
621
622                /**
623                 * Constructs a localized string source with the given locale and map of keys to localized strings.
624                 *
625                 * @param locale                the locale for these localized strings, not null
626                 * @param localizedStringsByKey localized strings by translation key, not null
627                 */
628                public LocalizedStringSource(@Nonnull Locale locale, @Nonnull Map<String, LocalizedString> localizedStringsByKey) {
629                        requireNonNull(locale);
630                        requireNonNull(localizedStringsByKey);
631
632                        this.locale = locale;
633                        this.localizedStringsByKey = localizedStringsByKey;
634                }
635
636                /**
637                 * Generates a {@code String} representation of this object.
638                 *
639                 * @return a string representation of this object, not null
640                 */
641                @Override
642                @Nonnull
643                public String toString() {
644                        return format("%s{locale=%s, localizedStringsByKey=%s", getClass().getSimpleName(), getLocale(), getLocalizedStringsByKey());
645                }
646
647                /**
648                 * Checks if this object is equal to another one.
649                 *
650                 * @param other the object to check, null returns false
651                 * @return true if this is equal to the other object, false otherwise
652                 */
653                @Override
654                public boolean equals(@Nullable Object other) {
655                        if (this == other)
656                                return true;
657
658                        if (other == null || !getClass().equals(other.getClass()))
659                                return false;
660
661                        LocalizedStringSource localizedStringSource = (LocalizedStringSource) other;
662
663                        return Objects.equals(getLocale(), localizedStringSource.getLocale())
664                                        && Objects.equals(getLocalizedStringsByKey(), localizedStringSource.getLocalizedStringsByKey());
665                }
666
667                /**
668                 * A hash code for this object.
669                 *
670                 * @return a suitable hash code
671                 */
672                @Override
673                public int hashCode() {
674                        return Objects.hash(getLocale(), getLocalizedStringsByKey());
675                }
676
677                @Nonnull
678                public Locale getLocale() {
679                        return locale;
680                }
681
682                @Nonnull
683                public Map<String, LocalizedString> getLocalizedStringsByKey() {
684                        return localizedStringsByKey;
685                }
686        }
687
688        /**
689         * Strategies for handling localized string lookup failures.
690         */
691        public enum FailureMode {
692                /**
693                 * The system will attempt a series of fallbacks in order to not throw an exception at runtime.
694                 * <p>
695                 * This mode is useful for production, where we often want program execution to continue in the face of
696                 * localization errors.
697                 */
698                USE_FALLBACK,
699                /**
700                 * The system will throw an exception if a localization is missing for the specified locale.
701                 * <p>
702                 * This mode is useful for testing, since problems are uncovered right away when execution halts.
703                 */
704                FAIL_FAST
705        }
706
707        /**
708         * Builder used to construct instances of {@link DefaultStrings}.
709         * <p>
710         * This class is intended for use by a single thread.
711         *
712         * @author <a href="https://revetkn.com">Mark Allen</a>
713         */
714        @NotThreadSafe
715        public static class Builder {
716                @Nonnull
717                private final Locale fallbackLocale;
718                @Nullable
719                private Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier;
720                @Nullable
721                private Function<LocaleMatcher, Locale> localeSupplier;
722                @Nullable
723                private Supplier<List<LanguageRange>> languageRangesSupplier;
724                @Nullable
725                private Map<String, List<Locale>> tiebreakerLocalesByLanguageCode;
726                @Nullable
727                private FailureMode failureMode;
728
729                /**
730                 * Constructs a strings builder with a default locale.
731                 *
732                 * @param fallbackLocale fallback locale, not null
733                 */
734                protected Builder(@Nonnull Locale fallbackLocale) {
735                        requireNonNull(fallbackLocale);
736                        this.fallbackLocale = fallbackLocale;
737                }
738
739                /**
740                 * Applies a localized string supplier to this builder.
741                 *
742                 * @param localizedStringSupplier localized string supplier, may be null
743                 * @return this builder instance, useful for chaining. not null
744                 */
745                @Nonnull
746                public Builder localizedStringSupplier(@Nullable Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier) {
747                        this.localizedStringSupplier = localizedStringSupplier;
748                        return this;
749                }
750
751                /**
752                 * Applies a locale supplier to this builder.
753                 *
754                 * @param localeSupplier locale supplier, may be null
755                 * @return this builder instance, useful for chaining. not null
756                 */
757                @Nonnull
758                public Builder localeSupplier(@Nullable Function<LocaleMatcher, Locale> localeSupplier) {
759                        this.localeSupplier = localeSupplier;
760                        return this;
761                }
762
763                /**
764                 * Applies a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales to this builder.
765                 *
766                 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null
767                 * @return this builder instance, useful for chaining. not null
768                 */
769                @Nonnull
770                public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<String, List<Locale>> tiebreakerLocalesByLanguageCode) {
771                        this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode;
772                        return this;
773                }
774
775                /**
776                 * Applies a failure mode to this builder.
777                 *
778                 * @param failureMode strategy for dealing with lookup failures, may be null
779                 * @return this builder instance, useful for chaining. not null
780                 */
781                @Nonnull
782                public Builder failureMode(@Nullable FailureMode failureMode) {
783                        this.failureMode = failureMode;
784                        return this;
785                }
786
787                /**
788                 * Constructs an instance of {@link DefaultStrings}.
789                 *
790                 * @return an instance of {@link DefaultStrings}, not null
791                 */
792                @Nonnull
793                public DefaultStrings build() {
794                        return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, failureMode);
795                }
796        }
797}