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 org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import java.util.List; 024import java.util.Locale; 025import java.util.Map; 026import java.util.Set; 027import java.util.function.Function; 028import java.util.function.Supplier; 029 030import static java.util.Objects.requireNonNull; 031 032/** 033 * Contract for localized string providers - given a key and optional caller-supplied placeholders, return a localized 034 * string. 035 * <p> 036 * Format is {@code "You are missing {{requiredFieldCount}} required fields."} 037 * <p> 038 * Each lookup observes one unmodifiable shallow snapshot of the caller-supplied placeholder map. Whole-message 039 * alternative predicates, {@link LocalizedString.ExpressionTranslation expression-fragment} predicates, and 040 * {@link LocalizedString.LanguageFormTranslation language-form} selectors read from that same snapshot for every 041 * locale candidate. Generated placeholder values do not become expression operands or language-form selector inputs; 042 * in particular, a language-form {@code value} or range endpoint always names raw caller input. 043 * <p> 044 * Generated-placeholder definitions declared by a {@link LocalizedString} are inherited along the selected 045 * whole-message alternative branch. The nearest selected descendant's complete definition replaces a same-named 046 * ancestor definition, unselected sibling definitions are invisible, and the resulting definition takes precedence 047 * over a same-named caller value when rendering output. Definitions are frozen for the selected branch before any 048 * generated fragment is resolved. 049 * 050 * @author <a href="https://revetkn.com">Mark Allen</a> 051 */ 052public interface Strings extends LocaleMatcher { 053 /** 054 * Gets a localized string for the given key. 055 * <p> 056 * If no localized string is available, the configured {@link TranslationFailureHandler} decides what happens. 057 * 058 * @param key the localization key, not null 059 * @return a localized string for the key, not null 060 */ 061 @NonNull 062 String get(@NonNull String key); 063 064 /** 065 * Gets a localized string for the given key and options. 066 * <p> 067 * If no localized string is available, the configured or per-invocation {@link TranslationFailureHandler} decides what happens. 068 * 069 * @param key the localization key, not null 070 * @param options per-invocation options, not null 071 * @return a localized string for the key, not null 072 * @since 3.0.0 073 */ 074 @NonNull 075 String get(@NonNull String key, @NonNull TranslationOptions options); 076 077 /** 078 * Gets a localized string for the given key. 079 * <p> 080 * If no localized string is available, the configured {@link TranslationFailureHandler} decides what happens. 081 * 082 * @param key the localization key, not null 083 * @param placeholders the placeholders to insert into the string, may be null 084 * @return a localized string for the key, not null 085 */ 086 @NonNull 087 String get(@NonNull String key, @Nullable Map<@NonNull String, @Nullable Object> placeholders); 088 089 /** 090 * Gets a localized string for the given key, placeholders, and options. 091 * <p> 092 * If no localized string is available, the configured or per-invocation {@link TranslationFailureHandler} decides what happens. 093 * 094 * @param key the localization key, not null 095 * @param placeholders the placeholders to insert into the string, may be null 096 * @param options per-invocation options, not null 097 * @return a localized string for the key, not null 098 * @since 3.0.0 099 */ 100 @NonNull 101 String get(@NonNull String key, 102 @Nullable Map<@NonNull String, @Nullable Object> placeholders, 103 @NonNull TranslationOptions options); 104 105 /** 106 * Resolves a localized string and returns diagnostic information about the lookup. 107 * 108 * @param key localization key, not null 109 * @return translation result, not null 110 * @since 3.0.0 111 */ 112 @NonNull 113 default TranslationResult getResult(@NonNull String key) { 114 return getResult(key, null, TranslationOptions.none()); 115 } 116 117 /** 118 * Resolves a localized string with per-invocation options and returns diagnostic information. 119 * 120 * @param key localization key, not null 121 * @param options per-invocation options, not null 122 * @return translation result, not null 123 * @since 3.0.0 124 */ 125 @NonNull 126 default TranslationResult getResult(@NonNull String key, @NonNull TranslationOptions options) { 127 return getResult(key, null, options); 128 } 129 130 /** 131 * Resolves a localized string with placeholders and returns diagnostic information. 132 * 133 * @param key localization key, not null 134 * @param placeholders caller-supplied placeholders, may be null 135 * @return translation result, not null 136 * @since 3.0.0 137 */ 138 @NonNull 139 default TranslationResult getResult(@NonNull String key, 140 @Nullable Map<@NonNull String, @Nullable Object> placeholders) { 141 return getResult(key, placeholders, TranslationOptions.none()); 142 } 143 144 /** 145 * Resolves a localized string with placeholders and options and returns diagnostic information. 146 * <p> 147 * A handler response that throws still throws; successful translations and return-key/return-string responses are 148 * represented by the returned result. 149 * A configured {@link TranslationFallbackObserver} runs once before a successful fallback result is returned; 150 * observer exceptions propagate directly to the caller. 151 * <p> 152 * The supplied map is shallow-copied once before the first locale candidate is attempted. Alternative predicates, 153 * language-form selectors, failure reporting, and every fallback candidate observe that same snapshot. Generated 154 * fragments are resolved separately and cannot change expression operands or selector inputs. 155 * 156 * @param key localization key, not null 157 * @param placeholders caller-supplied placeholders, may be null 158 * @param options per-invocation options, not null 159 * @return translation result, not null 160 * @since 3.0.0 161 */ 162 @NonNull 163 TranslationResult getResult(@NonNull String key, 164 @Nullable Map<@NonNull String, @Nullable Object> placeholders, 165 @NonNull TranslationOptions options); 166 167 /** 168 * Gets the locales for which localized strings were supplied. 169 * 170 * @return the supported locales, not null 171 * @since 3.0.0 172 */ 173 @NonNull 174 Set<@NonNull Locale> getSupportedLocales(); 175 176 /** 177 * Gets the localized string keys supplied for the given locale. 178 * 179 * @param locale locale to inspect, not null 180 * @return the localized string keys for the locale, not null 181 * @throws IllegalArgumentException if the locale is not supported 182 * @since 3.0.0 183 */ 184 @NonNull 185 Set<@NonNull String> getKeysForLocale(@NonNull Locale locale); 186 187 /** 188 * Gets the keys supplied by {@code sourceLocale} but missing from {@code targetLocale}. 189 * 190 * @param sourceLocale locale whose keys are used as the source set, not null 191 * @param targetLocale locale whose keys are compared against the source set, not null 192 * @return keys present in {@code sourceLocale} and missing from {@code targetLocale}, not null 193 * @throws IllegalArgumentException if either locale is not supported 194 * @since 3.0.0 195 */ 196 @NonNull 197 Set<@NonNull String> getMissingKeys(@NonNull Locale sourceLocale, @NonNull Locale targetLocale); 198 199 /** 200 * Vends a {@link Strings} instance builder for the specified fallback locale. 201 * <pre>{@code 202 * Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en")) 203 * .localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings")) 204 * .localeSupplier((matcher) -> matcher.bestMatchFor(Locale.forLanguageTag("en-GB"))) 205 * .build(); 206 * }</pre> 207 * 208 * @param fallbackLocale the fallback locale, not null 209 * @return a builder for a {@link Strings} instance, not null 210 * @throws IllegalArgumentException if the fallback locale is not a well-formed IETF BCP 47 locale 211 */ 212 static @NonNull Builder withFallbackLocale(@NonNull Locale fallbackLocale) { 213 LocaleUtils.requireWellFormed(fallbackLocale, "Fallback locale"); 214 return new Builder(fallbackLocale); 215 } 216 217 /** 218 * Builder used to construct {@link Strings} instances. 219 * <p> 220 * This class is intended for use by a single thread. 221 * 222 * @author <a href="https://revetkn.com">Mark Allen</a> 223 * @since 3.0.0 224 */ 225 @NotThreadSafe 226 class Builder { 227 @NonNull 228 private final Locale fallbackLocale; 229 @Nullable 230 private Supplier<@NonNull Map<@NonNull Locale, 231 ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier; 232 @Nullable 233 private Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier; 234 @Nullable 235 private Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier; 236 @Nullable 237 private Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode; 238 @Nullable 239 private TranslationFailureHandler translationFailureHandler; 240 @Nullable 241 private TranslationFallbackPolicy translationFallbackPolicy; 242 @Nullable 243 private TranslationFallbackObserver translationFallbackObserver; 244 @Nullable 245 private TranslationRuntimeLimits runtimeLimits; 246 @Nullable 247 private PhoneticResolver phoneticResolver; 248 @Nullable 249 private BidiIsolation bidiIsolation; 250 @Nullable 251 private LanguageRangeEquivalents languageRangeEquivalents; 252 253 /** 254 * Constructs a strings builder with a default locale. 255 * 256 * @param fallbackLocale fallback locale, not null 257 */ 258 Builder(@NonNull Locale fallbackLocale) { 259 requireNonNull(fallbackLocale); 260 this.fallbackLocale = fallbackLocale; 261 } 262 263 /** 264 * Applies a localized string supplier to this builder. 265 * <p> 266 * Locale keys returned by the supplier must be well-formed and must render to distinct IETF BCP 47 language tags. 267 * The returned map, its keys, each per-locale iterable, and every localized string must be non-null. Each 268 * per-locale iterable must contain unique translation keys. 269 * 270 * @param localizedStringSupplier localized string supplier, may be null 271 * @return this builder instance, useful for chaining. not null 272 */ 273 @NonNull 274 public Builder localizedStringSupplier(@Nullable Supplier<@NonNull Map<@NonNull Locale, 275 ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier) { 276 this.localizedStringSupplier = localizedStringSupplier; 277 return this; 278 } 279 280 /** 281 * Applies a locale supplier to this builder. 282 * <p> 283 * The supplier may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. It 284 * must return a well-formed IETF BCP 47 locale. 285 * 286 * @param localeSupplier locale supplier, may be null 287 * @return this builder instance, useful for chaining. not null 288 */ 289 @NonNull 290 public Builder localeSupplier( 291 @Nullable Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier) { 292 this.localeSupplier = localeSupplier; 293 294 if (localeSupplier != null) 295 this.localeMatchSupplier = null; 296 297 return this; 298 } 299 300 /** 301 * Applies a locale-negotiation-result supplier to this builder. 302 * <p> 303 * Prefer this over {@link #localeSupplier(Function)} when callers of {@link #getResult(String)} should receive the 304 * original negotiation diagnostics. The supplier may be invoked concurrently and must be thread-safe. Supplying a 305 * non-null value replaces any locale supplier. 306 * 307 * @param localeMatchSupplier locale-match supplier, may be null 308 * @return this builder, not null 309 * @since 3.0.0 310 */ 311 @NonNull 312 public Builder localeMatchSupplier( 313 @Nullable Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier) { 314 this.localeMatchSupplier = localeMatchSupplier; 315 316 if (localeMatchSupplier != null) 317 this.localeSupplier = null; 318 319 return this; 320 } 321 322 /** 323 * Applies a mapping of a well-formed IETF BCP 47 primary language subtag to its ordered "tiebreaker" fallback 324 * locales to this builder. 325 * <p> 326 * Deprecated language aliases are canonicalized, so aliases such as {@code he} and {@code iw} must not both be 327 * supplied. Each list must be a duplicate-free exact permutation of the loaded {@link Locale}s whose normalized 328 * primary language matches its key. This configuration is validated by {@link #build()}. 329 * 330 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null; keys, lists, and locales 331 * must not be null 332 * @return this builder instance, useful for chaining. not null 333 */ 334 @NonNull 335 public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) { 336 this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode; 337 return this; 338 } 339 340 /** 341 * Applies a phonetic resolver to this builder. 342 * <p> 343 * The resolver may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 344 * 345 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver) 346 * @return this builder instance, useful for chaining. not null 347 */ 348 @NonNull 349 public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) { 350 this.phoneticResolver = phoneticResolver; 351 return this; 352 } 353 354 /** 355 * Applies a translation failure handler to this builder. 356 * <p> 357 * The handler may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 358 * 359 * @param translationFailureHandler handler for failed lookups, may be null (defaults to returning the key) 360 * @return this builder instance, useful for chaining. not null 361 */ 362 @NonNull 363 public Builder translationFailureHandler(@Nullable TranslationFailureHandler translationFailureHandler) { 364 this.translationFailureHandler = translationFailureHandler; 365 return this; 366 } 367 368 /** 369 * Applies the policy that decides whether failed locale attempts continue to fallback candidates. 370 * <p> 371 * The policy may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 372 * The default {@link TranslationFallbackPolicy#fallbackOnMissingTranslationOrNoMatchingAlternative()} policy stops 373 * on {@link TranslationFailureReason#RESOLUTION_FAILURE}, including failures while evaluating an expression-fragment 374 * predicate or interpolating its selected/default fragment. 375 * 376 * @param translationFallbackPolicy locale fallback policy, may be null to use the safe default 377 * @return this builder, not null 378 */ 379 @NonNull 380 public Builder translationFallbackPolicy(@Nullable TranslationFallbackPolicy translationFallbackPolicy) { 381 this.translationFallbackPolicy = translationFallbackPolicy; 382 return this; 383 } 384 385 /** 386 * Applies an observer for translations supplied by a later locale candidate after earlier candidates failed. 387 * <p> 388 * The observer runs once before a successful lookup returns. It may be invoked concurrently and must be 389 * thread-safe. Observer exceptions propagate directly to the caller. 390 * 391 * @param translationFallbackObserver successful fallback observer, may be null to disable observation 392 * @return this builder, not null 393 * @since 3.1.1 394 */ 395 @NonNull 396 public Builder translationFallbackObserver(@Nullable TranslationFallbackObserver translationFallbackObserver) { 397 this.translationFallbackObserver = translationFallbackObserver; 398 return this; 399 } 400 401 /** 402 * Applies safety limits to localized strings construction and translation evaluation. 403 * <p> 404 * Expression limits apply to both whole-message alternatives and 405 * {@link LocalizedString.ExpressionAlternative expression-fragment alternatives}. Generated-placeholder depth and 406 * expansion limits are shared by {@link LocalizedString.LanguageFormTranslation language-form} and 407 * {@link LocalizedString.ExpressionTranslation expression-selected} fragments. The interpolated-output limit also 408 * bounds caller-supplied {@link CharSequence} values materialized for phonetic resolution. 409 * 410 * @param runtimeLimits runtime limits, may be null to use the library defaults 411 * @return this builder, not null 412 * @since 3.0.0 413 */ 414 @NonNull 415 public Builder runtimeLimits(@Nullable TranslationRuntimeLimits runtimeLimits) { 416 this.runtimeLimits = runtimeLimits; 417 return this; 418 } 419 420 /** 421 * Applies bidirectional isolation behavior for caller-supplied placeholder values. 422 * <p> 423 * Translation-owned text in generated fragments is not isolated; caller-supplied values interpolated into those 424 * fragments use this policy. 425 * 426 * @param bidiIsolation bidi isolation behavior, may be null (defaults to isolating caller-supplied values in RTL locales) 427 * @return this builder instance, useful for chaining. not null 428 */ 429 @NonNull 430 public Builder bidiIsolation(@Nullable BidiIsolation bidiIsolation) { 431 this.bidiIsolation = bidiIsolation; 432 return this; 433 } 434 435 /** 436 * Applies the source of IANA language-range equivalences, such as the {@code he}/{@code iw} pair, for this instance. 437 * <p> 438 * The source governs {@link LocaleMatcher#parseLanguageRanges(String)}, {@link LocaleMatcher#bestMatchForAcceptLanguage(String)}, 439 * and the equivalences locale matching recognizes for each requested range, so it can change which loaded locale a 440 * request selects. {@link LanguageRangeEquivalents#IANA_REGISTRY} uses the IANA Language Subtag Registry snapshot 441 * bundled in Lokalized and supplies the same equivalents on every JDK; {@link LanguageRangeEquivalents#JDK} uses 442 * the running JDK's table, as Lokalized 3.0.0 did. A range list parsed by the caller with 443 * {@link java.util.Locale.LanguageRange#parse(String)} carries the JDK's equivalents whatever this is set to; parse 444 * it with {@link LocaleMatcher#parseLanguageRanges(String)} to apply this setting. 445 * 446 * @param languageRangeEquivalents equivalence source, may be null (defaults to {@link LanguageRangeEquivalents#IANA_REGISTRY}) 447 * @return this builder instance, useful for chaining. not null 448 * @since 3.1.0 449 */ 450 @NonNull 451 public Builder languageRangeEquivalents(@Nullable LanguageRangeEquivalents languageRangeEquivalents) { 452 this.languageRangeEquivalents = languageRangeEquivalents; 453 return this; 454 } 455 456 /** 457 * Constructs a {@link Strings} instance. 458 * 459 * @return a {@link Strings} instance, not null 460 * @throws IllegalArgumentException if required localized-string or locale suppliers are missing, a configured locale 461 * is malformed, localized-string locale keys render to duplicate language tags, 462 * tiebreaker keys are invalid or collide after canonicalization, a tiebreaker list is 463 * not an exact permutation of its language's loaded locales, or supplied localized 464 * strings are invalid, including an alternative graph nested more than 128 levels deep 465 * @throws ExpressionEvaluationException if an expression cannot be compiled, including when it exceeds the 466 * configured runtime limits 467 */ 468 @NonNull 469 public Strings build() { 470 return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, localeMatchSupplier, 471 tiebreakerLocalesByLanguageCode, 472 translationFailureHandler, phoneticResolver, bidiIsolation, translationFallbackPolicy, runtimeLimits, 473 languageRangeEquivalents, translationFallbackObserver); 474 } 475 } 476}