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