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 * <p> 150 * The supplied map is shallow-copied once before the first locale candidate is attempted. Alternative predicates, 151 * language-form selectors, failure reporting, and every fallback candidate observe that same snapshot. Generated 152 * fragments are resolved separately and cannot change expression operands or selector inputs. 153 * 154 * @param key localization key, not null 155 * @param placeholders caller-supplied placeholders, may be null 156 * @param options per-invocation options, not null 157 * @return translation result, not null 158 * @since 3.0.0 159 */ 160 @NonNull 161 TranslationResult getResult(@NonNull String key, 162 @Nullable Map<@NonNull String, @Nullable Object> placeholders, 163 @NonNull TranslationOptions options); 164 165 /** 166 * Gets the locales for which localized strings were supplied. 167 * 168 * @return the supported locales, not null 169 * @since 3.0.0 170 */ 171 @NonNull 172 Set<@NonNull Locale> getSupportedLocales(); 173 174 /** 175 * Gets the localized string keys supplied for the given locale. 176 * 177 * @param locale locale to inspect, not null 178 * @return the localized string keys for the locale, not null 179 * @throws IllegalArgumentException if the locale is not supported 180 * @since 3.0.0 181 */ 182 @NonNull 183 Set<@NonNull String> getKeysForLocale(@NonNull Locale locale); 184 185 /** 186 * Gets the keys supplied by {@code sourceLocale} but missing from {@code targetLocale}. 187 * 188 * @param sourceLocale locale whose keys are used as the source set, not null 189 * @param targetLocale locale whose keys are compared against the source set, not null 190 * @return keys present in {@code sourceLocale} and missing from {@code targetLocale}, not null 191 * @throws IllegalArgumentException if either locale is not supported 192 * @since 3.0.0 193 */ 194 @NonNull 195 Set<@NonNull String> getMissingKeys(@NonNull Locale sourceLocale, @NonNull Locale targetLocale); 196 197 /** 198 * Vends a {@link Strings} instance builder for the specified fallback locale. 199 * <pre>{@code 200 * Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en")) 201 * .localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings")) 202 * .localeSupplier((matcher) -> matcher.bestMatchFor(Locale.forLanguageTag("en-GB"))) 203 * .build(); 204 * }</pre> 205 * 206 * @param fallbackLocale the fallback locale, not null 207 * @return a builder for a {@link Strings} instance, not null 208 * @throws IllegalArgumentException if the fallback locale is not a well-formed IETF BCP 47 locale 209 */ 210 static @NonNull Builder withFallbackLocale(@NonNull Locale fallbackLocale) { 211 LocaleUtils.requireWellFormed(fallbackLocale, "Fallback locale"); 212 return new Builder(fallbackLocale); 213 } 214 215 /** 216 * Builder used to construct {@link Strings} instances. 217 * <p> 218 * This class is intended for use by a single thread. 219 * 220 * @author <a href="https://revetkn.com">Mark Allen</a> 221 * @since 3.0.0 222 */ 223 @NotThreadSafe 224 class Builder { 225 @NonNull 226 private final Locale fallbackLocale; 227 @Nullable 228 private Supplier<@NonNull Map<@NonNull Locale, 229 ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier; 230 @Nullable 231 private Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier; 232 @Nullable 233 private Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier; 234 @Nullable 235 private Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode; 236 @Nullable 237 private TranslationFailureHandler translationFailureHandler; 238 @Nullable 239 private TranslationFallbackPolicy translationFallbackPolicy; 240 @Nullable 241 private TranslationRuntimeLimits runtimeLimits; 242 @Nullable 243 private PhoneticResolver phoneticResolver; 244 @Nullable 245 private BidiIsolation bidiIsolation; 246 247 /** 248 * Constructs a strings builder with a default locale. 249 * 250 * @param fallbackLocale fallback locale, not null 251 */ 252 Builder(@NonNull Locale fallbackLocale) { 253 requireNonNull(fallbackLocale); 254 this.fallbackLocale = fallbackLocale; 255 } 256 257 /** 258 * Applies a localized string supplier to this builder. 259 * <p> 260 * Locale keys returned by the supplier must be well-formed and must render to distinct IETF BCP 47 language tags. 261 * The returned map, its keys, each per-locale iterable, and every localized string must be non-null. Each 262 * per-locale iterable must contain unique translation keys. 263 * 264 * @param localizedStringSupplier localized string supplier, may be null 265 * @return this builder instance, useful for chaining. not null 266 */ 267 @NonNull 268 public Builder localizedStringSupplier(@Nullable Supplier<@NonNull Map<@NonNull Locale, 269 ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier) { 270 this.localizedStringSupplier = localizedStringSupplier; 271 return this; 272 } 273 274 /** 275 * Applies a locale supplier to this builder. 276 * <p> 277 * The supplier may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. It 278 * must return a well-formed IETF BCP 47 locale. 279 * 280 * @param localeSupplier locale supplier, may be null 281 * @return this builder instance, useful for chaining. not null 282 */ 283 @NonNull 284 public Builder localeSupplier( 285 @Nullable Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier) { 286 this.localeSupplier = localeSupplier; 287 288 if (localeSupplier != null) 289 this.localeMatchSupplier = null; 290 291 return this; 292 } 293 294 /** 295 * Applies a locale-negotiation-result supplier to this builder. 296 * <p> 297 * Prefer this over {@link #localeSupplier(Function)} when callers of {@link #getResult(String)} should receive the 298 * original negotiation diagnostics. The supplier may be invoked concurrently and must be thread-safe. Supplying a 299 * non-null value replaces any locale supplier. 300 * 301 * @param localeMatchSupplier locale-match supplier, may be null 302 * @return this builder, not null 303 * @since 3.0.0 304 */ 305 @NonNull 306 public Builder localeMatchSupplier( 307 @Nullable Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier) { 308 this.localeMatchSupplier = localeMatchSupplier; 309 310 if (localeMatchSupplier != null) 311 this.localeSupplier = null; 312 313 return this; 314 } 315 316 /** 317 * Applies a mapping of a well-formed IETF BCP 47 primary language subtag to its ordered "tiebreaker" fallback 318 * locales to this builder. 319 * <p> 320 * Deprecated language aliases are canonicalized, so aliases such as {@code he} and {@code iw} must not both be 321 * supplied. Each list must be a duplicate-free exact permutation of the loaded {@link Locale}s whose normalized 322 * primary language matches its key. This configuration is validated by {@link #build()}. 323 * 324 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null; keys, lists, and locales 325 * must not be null 326 * @return this builder instance, useful for chaining. not null 327 */ 328 @NonNull 329 public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) { 330 this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode; 331 return this; 332 } 333 334 /** 335 * Applies a phonetic resolver to this builder. 336 * <p> 337 * The resolver may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 338 * 339 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver) 340 * @return this builder instance, useful for chaining. not null 341 */ 342 @NonNull 343 public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) { 344 this.phoneticResolver = phoneticResolver; 345 return this; 346 } 347 348 /** 349 * Applies a translation failure handler to this builder. 350 * <p> 351 * The handler may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 352 * 353 * @param translationFailureHandler handler for failed lookups, may be null (defaults to returning the key) 354 * @return this builder instance, useful for chaining. not null 355 */ 356 @NonNull 357 public Builder translationFailureHandler(@Nullable TranslationFailureHandler translationFailureHandler) { 358 this.translationFailureHandler = translationFailureHandler; 359 return this; 360 } 361 362 /** 363 * Applies the policy that decides whether failed locale attempts continue to fallback candidates. 364 * <p> 365 * The policy may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. 366 * The default {@link TranslationFallbackPolicy#fallbackOnMissingTranslationOrNoMatchingAlternative()} policy stops 367 * on {@link TranslationFailureReason#RESOLUTION_FAILURE}, including failures while evaluating an expression-fragment 368 * predicate or interpolating its selected/default fragment. 369 * 370 * @param translationFallbackPolicy locale fallback policy, may be null to use the safe default 371 * @return this builder, not null 372 */ 373 @NonNull 374 public Builder translationFallbackPolicy(@Nullable TranslationFallbackPolicy translationFallbackPolicy) { 375 this.translationFallbackPolicy = translationFallbackPolicy; 376 return this; 377 } 378 379 /** 380 * Applies safety limits to localized strings construction and translation evaluation. 381 * <p> 382 * Expression limits apply to both whole-message alternatives and 383 * {@link LocalizedString.ExpressionAlternative expression-fragment alternatives}. Generated-placeholder depth and 384 * expansion limits are shared by {@link LocalizedString.LanguageFormTranslation language-form} and 385 * {@link LocalizedString.ExpressionTranslation expression-selected} fragments. The interpolated-output limit also 386 * bounds caller-supplied {@link CharSequence} values materialized for phonetic resolution. 387 * 388 * @param runtimeLimits runtime limits, may be null to use the library defaults 389 * @return this builder, not null 390 * @since 3.0.0 391 */ 392 @NonNull 393 public Builder runtimeLimits(@Nullable TranslationRuntimeLimits runtimeLimits) { 394 this.runtimeLimits = runtimeLimits; 395 return this; 396 } 397 398 /** 399 * Applies bidirectional isolation behavior for caller-supplied placeholder values. 400 * <p> 401 * Translation-owned text in generated fragments is not isolated; caller-supplied values interpolated into those 402 * fragments use this policy. 403 * 404 * @param bidiIsolation bidi isolation behavior, may be null (defaults to isolating caller-supplied values in RTL locales) 405 * @return this builder instance, useful for chaining. not null 406 */ 407 @NonNull 408 public Builder bidiIsolation(@Nullable BidiIsolation bidiIsolation) { 409 this.bidiIsolation = bidiIsolation; 410 return this; 411 } 412 413 /** 414 * Constructs a {@link Strings} instance. 415 * 416 * @return a {@link Strings} instance, not null 417 * @throws IllegalArgumentException if required localized-string or locale suppliers are missing, a configured locale 418 * is malformed, localized-string locale keys render to duplicate language tags, 419 * tiebreaker keys are invalid or collide after canonicalization, a tiebreaker list is 420 * not an exact permutation of its language's loaded locales, or supplied localized 421 * strings are invalid, including an alternative graph nested more than 128 levels deep 422 * @throws ExpressionEvaluationException if an expression cannot be compiled, including when it exceeds the 423 * configured runtime limits 424 */ 425 @NonNull 426 public Strings build() { 427 return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, localeMatchSupplier, 428 tiebreakerLocalesByLanguageCode, 429 translationFailureHandler, phoneticResolver, bidiIsolation, translationFallbackPolicy, runtimeLimits); 430 } 431 } 432}