001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 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;
021import org.jspecify.annotations.NonNull;
022import org.jspecify.annotations.Nullable;
023
024import javax.annotation.concurrent.NotThreadSafe;
025import javax.annotation.concurrent.ThreadSafe;
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.Comparator;
029import java.util.HashMap;
030import java.util.HashSet;
031import java.util.LinkedHashMap;
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.Optional;
039import java.util.Set;
040import java.util.function.Function;
041import java.util.function.Supplier;
042import java.util.logging.Logger;
043import java.util.stream.Collectors;
044
045import static java.lang.String.format;
046import static java.util.Objects.requireNonNull;
047
048/**
049 * Default implementation of a localized string provider.
050 * <p>
051 * It is recommended to use a single instance of this class across your entire application.
052 * <p>
053 * In multi-tenant systems like a web application where each user might have a different locale,
054 * your {@code localeSupplier} might return the locale specified by the current request, e.g.
055 * from a set of {@link LanguageRange} parsed from the {@code Accept-Language} header.
056 *
057 * @author <a href="https://revetkn.com">Mark Allen</a>
058 */
059@ThreadSafe
060public class DefaultStrings implements Strings {
061        @NonNull
062        private static final PhoneticResolver DEFAULT_PHONETIC_RESOLVER;
063
064        static {
065                DEFAULT_PHONETIC_RESOLVER = (term, locale) -> {
066                        throw new IllegalStateException(format("No %s was configured. Provide one via %s.Builder#phoneticResolver(...)",
067                                        PhoneticResolver.class.getSimpleName(), Strings.class.getSimpleName()));
068                };
069        }
070
071        @NonNull
072        private final Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale;
073        @NonNull
074        private final Function<LocaleMatcher, Locale> localeSupplier;
075        @NonNull
076        private final Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
077        @NonNull
078        private final FailureMode failureMode;
079        @NonNull
080        private final Locale fallbackLocale;
081        @NonNull
082        private final StringInterpolator stringInterpolator;
083        @NonNull
084        private final ExpressionEvaluator expressionEvaluator;
085        @NonNull
086        private final PhoneticResolver phoneticResolver;
087        @NonNull
088        private final Logger logger;
089
090        /**
091         * Cache of localized strings by key by locale.
092         * <p>
093         * This is our "master" reference localized string storage that other data structures will point to.
094         */
095        @NonNull
096        private final Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale;
097
098        /**
099         * Vends a builder suitable for constructing {@link DefaultStrings) instances.
100         * <p>
101         * This method is package-private and designed to be invoked via {@link Strings#withFallbackLocale(Locale)}.
102         *
103         * @param fallbackLocale the fallback locale used if no others match, not null
104         * @return the builder, not null
105         */
106        @NonNull
107        static Builder withFallbackLocale(@NonNull Locale fallbackLocale) {
108                requireNonNull(fallbackLocale);
109                return new Builder(fallbackLocale);
110        }
111
112        /**
113         * Constructs a localized string provider with builder-supplied data.
114         *
115         * @param fallbackLocale                  fallback locale, not null
116         * @param localizedStringSupplier         supplier of localized strings, not null
117         * @param localeSupplier                  locale supplier, may not be null
118         * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null
119         * @param failureMode                     strategy for dealing with lookup failures, may be null
120         */
121        protected DefaultStrings(@NonNull Locale fallbackLocale,
122                                                                                                         @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier,
123                                                                                                         @NonNull Function<LocaleMatcher, Locale> localeSupplier,
124                                                                                                         @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode,
125                                                                                                         @Nullable FailureMode failureMode) {
126                this(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, failureMode, null);
127        }
128
129        /**
130         * Constructs a localized string provider with builder-supplied data.
131         *
132         * @param fallbackLocale                  fallback locale, not null
133         * @param localizedStringSupplier         supplier of localized strings, not null
134         * @param localeSupplier                  locale supplier, may not be null
135         * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null
136         * @param failureMode                     strategy for dealing with lookup failures, may be null
137         * @param phoneticResolver                resolver for phonetic categories, may be null (defaults to fail-fast resolver)
138         */
139        protected DefaultStrings(@NonNull Locale fallbackLocale,
140                                                                                                         @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier,
141                                                                                                         @NonNull Function<LocaleMatcher, Locale> localeSupplier,
142                                                                                                         @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode,
143                                                                                                         @Nullable FailureMode failureMode,
144                                                                                                         @Nullable PhoneticResolver phoneticResolver) {
145                requireNonNull(fallbackLocale);
146                requireNonNull(localizedStringSupplier, format("You must specify a 'localizedStringSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
147                requireNonNull(localeSupplier, format("You must specify a 'localeSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName()));
148
149                this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName());
150
151                Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get();
152
153                if (suppliedLocalizedStringsByLocale == null)
154                        suppliedLocalizedStringsByLocale = Collections.emptyMap();
155
156                // Defensive copy of iterator to unmodifiable set
157                Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream()
158                                .collect(Collectors.toMap(
159                                                entry -> entry.getKey(),
160                                                entry -> {
161                                                        Set<@NonNull LocalizedString> localizedStrings = new LinkedHashSet<>();
162                                                        entry.getValue().forEach(localizedStrings::add);
163                                                        return Collections.unmodifiableSet(localizedStrings);
164                                                }
165                                ));
166
167                this.fallbackLocale = fallbackLocale;
168                this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale);
169
170                // Make our own mapping of tiebreakers based on the provided mapping.
171                // First, defensive copy, then add to the map as needed below.
172                Map<@NonNull String, @Nullable List<@NonNull Locale>> internalTiebreakerLocalesByLanguageCode = new HashMap<>();
173
174                if (tiebreakerLocalesByLanguageCode != null) {
175                        for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : tiebreakerLocalesByLanguageCode.entrySet()) {
176                                @Nullable List<@NonNull Locale> locales = entry.getValue();
177                                internalTiebreakerLocalesByLanguageCode.put(entry.getKey(),
178                                                locales == null ? null : new ArrayList<>(locales));
179                        }
180                }
181
182                // Verify tiebreakers are provided to support locale resolution when ambiguity exists.
183                // For each language code, if there is more than 1 localized strings file that matches it, tiebreakers must be provided.
184                Map<@NonNull String, @NonNull Set<@NonNull Locale>> supportedLocalesByLanguageCode = new HashMap<>(localizedStringsByLocale.size());
185
186                for (Locale supportedLocale : localizedStringsByLocale.keySet()) {
187                        String languageCode = supportedLocale.getLanguage();
188                        Set<@NonNull Locale> locales = supportedLocalesByLanguageCode.get(languageCode);
189
190                        if (locales == null) {
191                                locales = new HashSet<>();
192                                supportedLocalesByLanguageCode.put(languageCode, locales);
193                        }
194
195                        locales.add(supportedLocale);
196                }
197
198                for (Entry<@NonNull String, @NonNull Set<@NonNull Locale>> entry : supportedLocalesByLanguageCode.entrySet()) {
199                        String languageCode = entry.getKey();
200                        List<@NonNull Locale> locales = entry.getValue().stream()
201                                        .sorted(Comparator.comparing(Locale::toLanguageTag))
202                                        .collect(Collectors.toList());
203
204                        if (locales.size() == 1) {
205                                // If there is exactly 1 locale for the language code, it's its own "identity" tiebreaker.
206                                internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(locales));
207                        } else if (locales.size() > 1) {
208                                // We need to provide tiebreakers if a locale has more than 1 strings file.
209                                @Nullable List<@NonNull Locale> providedTiebreakerLocales = internalTiebreakerLocalesByLanguageCode.get(languageCode);
210
211                                if (providedTiebreakerLocales == null || providedTiebreakerLocales.size() == 0) {
212                                        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",
213                                                        languageCode, locales.stream().map(locale -> locale.toLanguageTag()).collect(Collectors.toList())));
214                                } else {
215                                        // First, verify that all tiebreakers actually exist
216                                        Set<@NonNull Locale> supportedLocales = localizedStringsByLocale.keySet();
217
218                                        for (Locale providedTiebreakerLocale : providedTiebreakerLocales)
219                                                if (!supportedLocales.contains(providedTiebreakerLocale))
220                                                        throw new IllegalArgumentException(format("Tiebreaker locale '%s' specified in 'tiebreakerLocalesByLanguageCode' does not have a localized strings file. Supported locales are: %s",
221                                                                        providedTiebreakerLocale.toLanguageTag(), supportedLocales.stream().map(supportedLocale -> supportedLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
222
223                                        // Next, verify that tiebreakers are exhaustively specified
224                                        List<@NonNull Locale> missingLocales = new ArrayList<>(locales.size());
225
226                                        for (Locale locale : locales)
227                                                if (!providedTiebreakerLocales.contains(locale))
228                                                        missingLocales.add(locale);
229
230                                        if (missingLocales.size() > 0)
231                                                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",
232                                                                providedTiebreakerLocales.stream().map(providedTiebreakerLocale -> providedTiebreakerLocale.toLanguageTag()).sorted().collect(Collectors.toList()),
233                                                                languageCode,
234                                                                missingLocales.stream().map(missingLocale -> missingLocale.toLanguageTag()).sorted().collect(Collectors.toList())));
235                                }
236
237                                internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(providedTiebreakerLocales));
238                        } else {
239                                // Should never occur
240                                throw new IllegalStateException("No locales match language code");
241                        }
242                }
243
244                Map<@NonNull String, @Nullable List<@NonNull Locale>> finalizedTiebreakerLocalesByLanguageCode = new HashMap<>(internalTiebreakerLocalesByLanguageCode.size());
245
246                for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : internalTiebreakerLocalesByLanguageCode.entrySet()) {
247                        @Nullable List<@NonNull Locale> locales = entry.getValue();
248                        finalizedTiebreakerLocalesByLanguageCode.put(entry.getKey(),
249                                        locales == null ? null : Collections.unmodifiableList(new ArrayList<>(locales)));
250                }
251
252                this.tiebreakerLocalesByLanguageCode = Collections.unmodifiableMap(finalizedTiebreakerLocalesByLanguageCode);
253
254                this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode;
255                this.stringInterpolator = new StringInterpolator();
256                this.phoneticResolver = phoneticResolver == null ? DEFAULT_PHONETIC_RESOLVER : phoneticResolver;
257                this.expressionEvaluator = new ExpressionEvaluator(null, this.phoneticResolver);
258
259                Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale =
260                                new HashMap<>(localizedStringsByLocale.size());
261
262                for (Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : localizedStringsByLocale.entrySet()) {
263                        Locale locale = entry.getKey();
264                        Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = new LinkedHashMap<>();
265
266                        for (LocalizedString localizedString : entry.getValue()) {
267                                if (localizedString == null)
268                                        throw new IllegalArgumentException(format("Null localized string encountered for locale '%s'", locale.toLanguageTag()));
269
270                                String key = localizedString.getKey();
271                                LocalizedString existing = localizedStringsByKey.putIfAbsent(key, localizedString);
272
273                                if (existing != null)
274                                        throw new IllegalArgumentException(format("Duplicate localized string key '%s' encountered for locale '%s'", key, locale.toLanguageTag()));
275                        }
276
277                        localizedStringsByKeyByLocale.put(locale, Collections.unmodifiableMap(localizedStringsByKey));
278                }
279
280                this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByKeyByLocale);
281
282                if (!localizedStringsByLocale.containsKey(getFallbackLocale()))
283                        throw new IllegalArgumentException(format("Specified fallback locale is '%s' but no matching " +
284                                                        "localized strings locale was found. Known locales: [%s]", fallbackLocale.toLanguageTag(),
285                                        localizedStringsByLocale.keySet().stream()
286                                                        .map(locale -> locale.toLanguageTag())
287                                                        .sorted()
288                                                        .collect(Collectors.joining(", "))));
289
290                this.localeSupplier = localeSupplier;
291        }
292
293        @NonNull
294        @Override
295        public String get(@NonNull String key) {
296                requireNonNull(key);
297                return get(key, null, getLocaleSupplier().apply(this));
298        }
299
300        @NonNull
301        @Override
302        public String get(@NonNull String key,
303                                                                                @Nullable Map<@NonNull String, @Nullable Object> placeholders) {
304                requireNonNull(key);
305                return get(key, placeholders, getLocaleSupplier().apply(this));
306        }
307
308        @NonNull
309        protected String get(@NonNull String key,
310                                                                                         @Nullable Map<@NonNull String, @Nullable Object> placeholders,
311                                                                                         @NonNull Locale locale) {
312                requireNonNull(key);
313                requireNonNull(locale);
314
315                if (placeholders == null)
316                        placeholders = Collections.emptyMap();
317
318                Locale finalLocale = locale;
319                Map<@NonNull String, @Nullable Object> mutableContext = new HashMap<>(placeholders);
320                Map<@NonNull String, @Nullable Object> immutableContext = Collections.unmodifiableMap(new HashMap<>(placeholders));
321
322                @Nullable Map<@NonNull String, @NonNull LocalizedString> localizedStrings = getLocalizedStringsByKeyByLocale().get(locale);
323
324                if (localizedStrings == null) {
325                        finalLocale = getFallbackLocale();
326                        localizedStrings = getLocalizedStringsByKeyByLocale().get(getFallbackLocale());
327                }
328
329                // Should never occur
330                if (localizedStrings == null)
331                        throw new IllegalStateException(format("Unable to find strings file for both '%s' and fallback locale '%s'",
332                                        locale.toLanguageTag(), getFallbackLocale().toLanguageTag()));
333
334                LocalizedString localizedString = localizedStrings.get(key);
335                String translation = null;
336
337                if (localizedString != null)
338                        translation = getInternal(key, localizedString, mutableContext, immutableContext, finalLocale).orElse(null);
339
340                if (translation == null) {
341                        String message = format("No match for '%s' was found for locale '%s'.", key, locale.toLanguageTag());
342                        logger.finer(message);
343
344                        if (getFailureMode() == FailureMode.FAIL_FAST)
345                                throw new MissingTranslationException(message, key, placeholders, locale);
346
347                        // Not fail-fast?  Merge against the key itself in hopes that the key is a meaningful natural-language value
348                        translation = getStringInterpolator().interpolate(key, mutableContext);
349                }
350
351                return translation;
352        }
353
354        /**
355         * Recursive method which attempts to translate a localized string.
356         *
357         * @param key              the toplevel translation key (always the same regardless of recursion depth), not null
358         * @param localizedString  the localized string on which to operate, not null
359         * @param mutableContext   the mutable context for the translation, not null
360         * @param immutableContext the original user-supplied translation context, not null
361         * @param locale           the locale to use for evaluation, not null
362         * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null
363         */
364        @NonNull
365        protected Optional<String> getInternal(@NonNull String key, @NonNull LocalizedString localizedString,
366                                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> mutableContext,
367                                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> immutableContext,
368                                                                                                                                                                 @NonNull Locale locale) {
369                requireNonNull(key);
370                requireNonNull(localizedString);
371                requireNonNull(mutableContext);
372                requireNonNull(immutableContext);
373                requireNonNull(locale);
374
375                // First, see if any alternatives match by evaluating them
376                for (LocalizedString alternative : localizedString.getAlternatives()) {
377                        if (alternativeMatches(alternative, mutableContext, locale)) {
378                                logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext));
379
380                                // If we have a matching alternative, recurse into it
381                                return getInternal(key, alternative, mutableContext, immutableContext, locale);
382                        }
383                }
384
385                if (!localizedString.getTranslation().isPresent())
386                        return Optional.empty();
387
388                String translation = localizedString.getTranslation().get();
389
390                for (Entry<@NonNull String, @NonNull LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) {
391                        String placeholderName = entry.getKey();
392                        LanguageFormTranslation languageFormTranslation = entry.getValue();
393                        Object value = null;
394                        Object rangeStart = null;
395                        Object rangeEnd = null;
396                        Map<@NonNull Cardinality, @NonNull String> translationsByCardinality = new HashMap<>();
397                        Map<@NonNull Ordinality, @NonNull String> translationsByOrdinality = new HashMap<>();
398                        Map<@NonNull Gender, @NonNull String> translationsByGender = new HashMap<>();
399                        Map<@NonNull Formality, @NonNull String> translationsByFormality = new HashMap<>();
400                        Map<@NonNull Clusivity, @NonNull String> translationsByClusivity = new HashMap<>();
401                        Map<@NonNull Animacy, @NonNull String> translationsByAnimacy = new HashMap<>();
402                        Map<@NonNull Phonetic, @NonNull String> translationsByPhonetic = new HashMap<>();
403
404                        if (languageFormTranslation.getRange().isPresent()) {
405                                LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
406                                rangeStart = unwrapOptional(immutableContext.get(languageFormTranslationRange.getStart()));
407                                rangeEnd = unwrapOptional(immutableContext.get(languageFormTranslationRange.getEnd()));
408                        } else {
409                                value = unwrapOptional(immutableContext.get(languageFormTranslation.getValue().get()));
410                        }
411
412                        for (Entry<@NonNull LanguageForm, @NonNull String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) {
413                                LanguageForm languageForm = translationEntry.getKey();
414                                String translatedLanguageForm = translationEntry.getValue();
415
416                                if (languageForm instanceof Cardinality)
417                                        translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm);
418                                else if (languageForm instanceof Ordinality)
419                                        translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm);
420                                else if (languageForm instanceof Gender)
421                                        translationsByGender.put((Gender) languageForm, translatedLanguageForm);
422                                else if (languageForm instanceof Formality)
423                                        translationsByFormality.put((Formality) languageForm, translatedLanguageForm);
424                                else if (languageForm instanceof Clusivity)
425                                        translationsByClusivity.put((Clusivity) languageForm, translatedLanguageForm);
426                                else if (languageForm instanceof Animacy)
427                                        translationsByAnimacy.put((Animacy) languageForm, translatedLanguageForm);
428                                else if (languageForm instanceof Phonetic)
429                                        translationsByPhonetic.put((Phonetic) languageForm, translatedLanguageForm);
430                                else
431                                        throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm));
432                        }
433
434                        int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) +
435                                        (translationsByOrdinality.size() > 0 ? 1 : 0) +
436                                        (translationsByGender.size() > 0 ? 1 : 0) +
437                                        (translationsByFormality.size() > 0 ? 1 : 0) +
438                                        (translationsByClusivity.size() > 0 ? 1 : 0) +
439                                        (translationsByAnimacy.size() > 0 ? 1 : 0) +
440                                        (translationsByPhonetic.size() > 0 ? 1 : 0);
441
442                        if (distinctLanguageForms > 1)
443                                throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString));
444
445                        if (distinctLanguageForms == 0)
446                                continue;
447
448                        if (languageFormTranslation.getRange().isPresent() && translationsByCardinality.isEmpty())
449                                throw new IllegalArgumentException(format("Range-based translations are only supported for %s. Offending localized string was %s",
450                                                Cardinality.class.getSimpleName(), localizedString));
451
452                        // Handle plural cardinalities
453                        if (translationsByCardinality.size() > 0) {
454                                // Special case: calculate range from min and max if this is a range-driven cardinality
455                                if (languageFormTranslation.getRange().isPresent()) {
456                                        LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
457
458                                        if (rangeStart == null)
459                                                throw new IllegalArgumentException(format("Missing range start placeholder '%s' for key '%s'",
460                                                                languageFormTranslationRange.getStart(), key));
461
462                                        if (rangeEnd == null)
463                                                throw new IllegalArgumentException(format("Missing range end placeholder '%s' for key '%s'",
464                                                                languageFormTranslationRange.getEnd(), key));
465
466                                        if (!(rangeStart instanceof Number)) {
467                                                throw new IllegalArgumentException(format("Range start placeholder '%s' for key '%s' must be a %s but was %s",
468                                                                languageFormTranslationRange.getStart(), key, Number.class.getSimpleName(), rangeStart.getClass().getSimpleName()));
469                                        }
470
471                                        if (!(rangeEnd instanceof Number)) {
472                                                throw new IllegalArgumentException(format("Range end placeholder '%s' for key '%s' must be a %s but was %s",
473                                                                languageFormTranslationRange.getEnd(), key, Number.class.getSimpleName(), rangeEnd.getClass().getSimpleName()));
474                                        }
475
476                                        Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale);
477                                        Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale);
478                                        Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale);
479
480                                        String cardinalityTranslation = translationsByCardinality.get(rangeCardinality);
481
482                                        if (cardinalityTranslation == null)
483                                                throw new IllegalStateException(format("Missing %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s",
484                                                                Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString));
485
486                                        mutableContext.put(placeholderName, cardinalityTranslation);
487                                } else {
488                                        // Normal "non-range" cardinality
489                                        if (value == null)
490                                                throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
491                                                                languageFormTranslation.getValue().get(), key));
492
493                                        if (!(value instanceof Number)) {
494                                                throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
495                                                                languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName()));
496                                        }
497
498                                        Cardinality cardinality = Cardinality.forNumber((Number) value, locale);
499                                        String cardinalityTranslation = translationsByCardinality.get(cardinality);
500
501                                        if (cardinalityTranslation == null)
502                                                throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
503                                                                Cardinality.class.getSimpleName(), cardinality.name(), localizedString));
504
505                                        mutableContext.put(placeholderName, cardinalityTranslation);
506                                }
507                        }
508
509                        // Handle plural ordinalities
510                        if (translationsByOrdinality.size() > 0) {
511                                if (value == null)
512                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
513                                                        languageFormTranslation.getValue().get(), key));
514
515                                if (!(value instanceof Number)) {
516                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
517                                                        languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName()));
518                                }
519
520                                Ordinality ordinality = Ordinality.forNumber((Number) value, locale);
521                                String ordinalityTranslation = translationsByOrdinality.get(ordinality);
522
523                                if (ordinalityTranslation == null)
524                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
525                                                        Ordinality.class.getSimpleName(), ordinality.name(), localizedString));
526
527                                mutableContext.put(placeholderName, ordinalityTranslation);
528                        }
529
530                        // Handle genders
531                        if (translationsByGender.size() > 0) {
532                                if (value == null)
533                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
534                                                        languageFormTranslation.getValue().get(), key));
535
536                                if (!(value instanceof Gender))
537                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
538                                                        languageFormTranslation.getValue().get(), key, Gender.class.getSimpleName(), value.getClass().getSimpleName()));
539
540                                Gender gender = (Gender) value;
541                                String genderTranslation = translationsByGender.get(gender);
542
543                                if (genderTranslation == null)
544                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
545                                                        Gender.class.getSimpleName(), gender.name(), localizedString));
546
547                                mutableContext.put(placeholderName, genderTranslation);
548                        }
549
550                        // Handle formality
551                        if (translationsByFormality.size() > 0) {
552                                if (value == null)
553                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
554                                                        languageFormTranslation.getValue().get(), key));
555
556                                if (!(value instanceof Formality))
557                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
558                                                        languageFormTranslation.getValue().get(), key, Formality.class.getSimpleName(), value.getClass().getSimpleName()));
559
560                                Formality formality = (Formality) value;
561                                String formalityTranslation = translationsByFormality.get(formality);
562
563                                if (formalityTranslation == null)
564                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
565                                                        Formality.class.getSimpleName(), formality.name(), localizedString));
566
567                                mutableContext.put(placeholderName, formalityTranslation);
568                        }
569
570                        // Handle clusivity
571                        if (translationsByClusivity.size() > 0) {
572                                if (value == null)
573                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
574                                                        languageFormTranslation.getValue().get(), key));
575
576                                if (!(value instanceof Clusivity))
577                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
578                                                        languageFormTranslation.getValue().get(), key, Clusivity.class.getSimpleName(), value.getClass().getSimpleName()));
579
580                                Clusivity clusivity = (Clusivity) value;
581                                String clusivityTranslation = translationsByClusivity.get(clusivity);
582
583                                if (clusivityTranslation == null)
584                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
585                                                        Clusivity.class.getSimpleName(), clusivity.name(), localizedString));
586
587                                mutableContext.put(placeholderName, clusivityTranslation);
588                        }
589
590                        // Handle animacy
591                        if (translationsByAnimacy.size() > 0) {
592                                if (value == null)
593                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
594                                                        languageFormTranslation.getValue().get(), key));
595
596                                if (!(value instanceof Animacy))
597                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s",
598                                                        languageFormTranslation.getValue().get(), key, Animacy.class.getSimpleName(), value.getClass().getSimpleName()));
599
600                                Animacy animacy = (Animacy) value;
601                                String animacyTranslation = translationsByAnimacy.get(animacy);
602
603                                if (animacyTranslation == null)
604                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
605                                                        Animacy.class.getSimpleName(), animacy.name(), localizedString));
606
607                                mutableContext.put(placeholderName, animacyTranslation);
608                        }
609
610                        // Handle phonetics
611                        if (translationsByPhonetic.size() > 0) {
612                                if (languageFormTranslation.getRange().isPresent())
613                                        throw new IllegalArgumentException(format("Phonetic translations cannot use ranges. Offending localized string was %s", localizedString));
614
615                                if (value == null)
616                                        throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'",
617                                                        languageFormTranslation.getValue().get(), key));
618
619                                Phonetic phonetic;
620
621                                if (value instanceof Phonetic) {
622                                        phonetic = (Phonetic) value;
623                                } else if (value instanceof CharSequence) {
624                                        PhoneticResolver resolver = getPhoneticResolver();
625                                        phonetic = resolver.resolve(value.toString(), locale);
626
627                                        if (phonetic == null)
628                                                throw new IllegalArgumentException(format("%s returned null for placeholder '%s' in key '%s'",
629                                                                PhoneticResolver.class.getSimpleName(), languageFormTranslation.getValue().get(), key));
630                                } else {
631                                        throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s or %s but was %s",
632                                                        languageFormTranslation.getValue().get(), key, Phonetic.class.getSimpleName(),
633                                                        CharSequence.class.getSimpleName(), value.getClass().getSimpleName()));
634                                }
635
636                                String phoneticTranslation = translationsByPhonetic.get(phonetic);
637
638                                if (phoneticTranslation == null)
639                                        throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s",
640                                                        Phonetic.class.getSimpleName(), phonetic.name(), localizedString));
641
642                                mutableContext.put(placeholderName, phoneticTranslation);
643                        }
644                }
645
646                translation = getStringInterpolator().interpolate(translation, mutableContext);
647
648                return Optional.of(translation);
649        }
650
651        private boolean alternativeMatches(@NonNull LocalizedString alternative,
652                                                                                                                                                 @NonNull Map<@NonNull String, @Nullable Object> context,
653                                                                                                                                                 @NonNull Locale locale) {
654                requireNonNull(alternative);
655                requireNonNull(context);
656                requireNonNull(locale);
657
658                List<@NonNull Token> expressionTokens = alternative.getExpressionTokens();
659
660                if (expressionTokens != null)
661                        return getExpressionEvaluator().evaluateReversePolishNotationTokens(expressionTokens, context, locale);
662
663                return getExpressionEvaluator().evaluate(alternative.getKey(), context, locale);
664        }
665
666        @NonNull
667        @Override
668        public Locale bestMatchFor(@NonNull Locale locale) {
669                requireNonNull(locale);
670                return bestMatchFor(List.of(new LanguageRange(locale.toLanguageTag())));
671        }
672
673        @NonNull
674        @Override
675        public Locale bestMatchFor(@NonNull List<@NonNull LanguageRange> languageRanges) {
676                requireNonNull(languageRanges);
677
678                if (languageRanges.isEmpty())
679                        return getFallbackLocale();
680
681                List<@NonNull LanguageRange> sortedLanguageRanges = new ArrayList<>(languageRanges);
682                sortedLanguageRanges.sort(Comparator.comparingDouble(LanguageRange::getWeight).reversed());
683                List<@NonNull Locale> availableLocales = new ArrayList<>(getLocalizedStringsByLocale().keySet());
684                availableLocales.sort(Comparator.comparing(Locale::toLanguageTag));
685
686                // Walk through each LanguageRange in preference order
687                for (LanguageRange languageRange : sortedLanguageRanges) {
688                        String range = languageRange.getRange(); // e.g. "pt" or "pt-PT"
689                        double weight = languageRange.getWeight();
690
691                        if (weight <= 0)
692                                continue;
693
694                        if ("*".equals(range))
695                                return getFallbackLocale();
696
697                        // Exact tag match?
698                        for (Locale locale : availableLocales)
699                                if (locale.toLanguageTag().equalsIgnoreCase(range))
700                                        return locale;
701
702                        // Primary-tag candidates (e.g. "pt" or "pt-XX")
703                        String primary = range.split("-")[0]; // e.g. "pt"
704
705                        if ("*".equals(primary)) {
706                                List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), availableLocales,
707                                                Locale.FilteringMode.EXTENDED_FILTERING);
708
709                                if (!filteredCandidates.isEmpty())
710                                        return filteredCandidates.get(0);
711
712                                continue;
713                        }
714
715                        List<@NonNull Locale> candidates = availableLocales.stream()
716                                        .filter(locale -> locale.getLanguage().equalsIgnoreCase(primary))
717                                        .collect(Collectors.toList());
718
719                        if (candidates.isEmpty())
720                                continue; // try the next LanguageRange
721
722                        List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), candidates,
723                                        Locale.FilteringMode.EXTENDED_FILTERING);
724
725                        if (!filteredCandidates.isEmpty()) {
726                                boolean hasSpecificMatch = filteredCandidates.stream()
727                                                .anyMatch(locale -> !locale.toLanguageTag().equalsIgnoreCase(locale.getLanguage()));
728
729                                if (hasSpecificMatch)
730                                        candidates = filteredCandidates;
731                        }
732
733                        if (candidates.size() == 1)
734                                return candidates.get(0);
735
736                        // Tie‐breaker list for this primary tag?
737                        @Nullable List<@NonNull Locale> tiebreakers = getTiebreakerLocalesByLanguageCode().get(primary);
738
739                        if (tiebreakers != null)
740                                for (Locale tiebreaker : tiebreakers)
741                                        if (candidates.contains(tiebreaker))
742                                                return tiebreaker;
743
744                        return candidates.get(0);
745                }
746
747                // 4) Nothing matched at all
748                return getFallbackLocale();
749        }
750
751        @Nullable
752        private static Object unwrapOptional(@Nullable Object value) {
753                if (value instanceof Optional)
754                        return ((Optional<?>) value).orElse(null);
755
756                return value;
757        }
758
759        /**
760         * Gets the set of localized strings for each locale.
761         *
762         * @return the set of localized strings for each locale, not null
763         */
764        @NonNull
765        public Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> getLocalizedStringsByLocale() {
766                return localizedStringsByLocale;
767        }
768
769        /**
770         * Gets the locale supplier.
771         *
772         * @return the locale supplier, not null
773         */
774        @NonNull
775        public Function<LocaleMatcher, Locale> getLocaleSupplier() {
776                return this.localeSupplier;
777        }
778
779        /**
780         * Gets the mapping of a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales.
781         *
782         * @return the per-language-code "tiebreaker" locales, not null
783         */
784        @NonNull
785        public Map<@NonNull String, @Nullable List<@NonNull Locale>> getTiebreakerLocalesByLanguageCode() {
786                return this.tiebreakerLocalesByLanguageCode;
787        }
788
789        /**
790         * Gets the strategy for handling string lookup failures.
791         *
792         * @return the strategy for handling string lookup failures, not null
793         */
794        @NonNull
795        public FailureMode getFailureMode() {
796                return failureMode;
797        }
798
799        /**
800         * Gets the fallback locale.
801         *
802         * @return the fallback locale, not null
803         */
804        @NonNull
805        public Locale getFallbackLocale() {
806                return fallbackLocale;
807        }
808
809        /**
810         * Gets the string interpolator used to merge placeholders into translations.
811         *
812         * @return the string interpolator, not null
813         */
814        @NonNull
815        protected StringInterpolator getStringInterpolator() {
816                return stringInterpolator;
817        }
818
819        /**
820         * Gets the expression evaluator used to determine if alternative expressions match the evaluation context.
821         *
822         * @return the expression evaluator, not null
823         */
824        @NonNull
825        protected ExpressionEvaluator getExpressionEvaluator() {
826                return expressionEvaluator;
827        }
828
829        /**
830         * Gets the phonetic resolver used to determine phonetic categories.
831         *
832         * @return the phonetic resolver, not null
833         */
834        @NonNull
835        protected PhoneticResolver getPhoneticResolver() {
836                return phoneticResolver;
837        }
838
839        /**
840         * Gets our "master" cache of localized strings by key by locale.
841         *
842         * @return the cache of localized strings by key by locale, not null
843         */
844        @NonNull
845        protected Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> getLocalizedStringsByKeyByLocale() {
846                return localizedStringsByKeyByLocale;
847        }
848
849        /**
850         * Strategies for handling localized string lookup failures.
851         */
852        public enum FailureMode {
853                /**
854                 * The system will attempt a series of fallbacks in order to not throw an exception at runtime.
855                 * <p>
856                 * This mode is useful for production, where we often want program execution to continue in the face of
857                 * localization errors.
858                 */
859                USE_FALLBACK,
860                /**
861                 * The system will throw an exception if a localization is missing for the specified locale.
862                 * <p>
863                 * This mode is useful for testing, since problems are uncovered right away when execution halts.
864                 */
865                FAIL_FAST
866        }
867
868        /**
869         * Builder used to construct instances of {@link DefaultStrings}.
870         * <p>
871         * This class is intended for use by a single thread.
872         *
873         * @author <a href="https://revetkn.com">Mark Allen</a>
874         */
875        @NotThreadSafe
876        public static class Builder {
877                @NonNull
878                private final Locale fallbackLocale;
879                @Nullable
880                private Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier;
881                @Nullable
882                private Function<LocaleMatcher, Locale> localeSupplier;
883                @Nullable
884                private Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
885                @Nullable
886                private FailureMode failureMode;
887                @Nullable
888                private PhoneticResolver phoneticResolver;
889
890                /**
891                 * Constructs a strings builder with a default locale.
892                 *
893                 * @param fallbackLocale fallback locale, not null
894                 */
895                protected Builder(@NonNull Locale fallbackLocale) {
896                        requireNonNull(fallbackLocale);
897                        this.fallbackLocale = fallbackLocale;
898                }
899
900                /**
901                 * Applies a localized string supplier to this builder.
902                 *
903                 * @param localizedStringSupplier localized string supplier, may be null
904                 * @return this builder instance, useful for chaining. not null
905                 */
906                @NonNull
907                public Builder localizedStringSupplier(@Nullable Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier) {
908                        this.localizedStringSupplier = localizedStringSupplier;
909                        return this;
910                }
911
912                /**
913                 * Applies a locale supplier to this builder.
914                 *
915                 * @param localeSupplier locale supplier, may be null
916                 * @return this builder instance, useful for chaining. not null
917                 */
918                @NonNull
919                public Builder localeSupplier(@Nullable Function<LocaleMatcher, Locale> localeSupplier) {
920                        this.localeSupplier = localeSupplier;
921                        return this;
922                }
923
924                /**
925                 * Applies a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales to this builder.
926                 *
927                 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null
928                 * @return this builder instance, useful for chaining. not null
929                 */
930                @NonNull
931                public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) {
932                        this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode;
933                        return this;
934                }
935
936                /**
937                 * Applies a failure mode to this builder.
938                 *
939                 * @param failureMode strategy for dealing with lookup failures, may be null
940                 * @return this builder instance, useful for chaining. not null
941                 */
942                @NonNull
943                public Builder failureMode(@Nullable FailureMode failureMode) {
944                        this.failureMode = failureMode;
945                        return this;
946                }
947
948                /**
949                 * Applies a phonetic resolver to this builder.
950                 *
951                 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver)
952                 * @return this builder instance, useful for chaining. not null
953                 */
954                @NonNull
955                public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) {
956                        this.phoneticResolver = phoneticResolver;
957                        return this;
958                }
959
960                /**
961                 * Constructs an instance of {@link DefaultStrings}.
962                 *
963                 * @return an instance of {@link DefaultStrings}, not null
964                 */
965                @NonNull
966                public DefaultStrings build() {
967                        return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode,
968                                        failureMode, phoneticResolver);
969                }
970        }
971}