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 javax.annotation.concurrent.ThreadSafe; 024import java.util.ArrayList; 025import java.util.Collections; 026import java.util.List; 027import java.util.Locale; 028import java.util.Locale.LanguageRange; 029import java.util.Objects; 030import java.util.Optional; 031 032import static java.util.Objects.requireNonNull; 033 034/** 035 * Per-invocation options for localized string lookup. 036 * <p> 037 * These options override the defaults configured on a {@link Strings} instance for a single lookup. 038 * Instances are structurally immutable and safe to share. A configured {@link TranslationFailureHandler} or 039 * {@link TranslationFallbackPolicy} or {@link TranslationFallbackObserver} may be invoked concurrently when the same 040 * options are used by concurrent lookups; application-supplied implementations must therefore be thread-safe. 041 * 042 * @author <a href="https://revetkn.com">Mark Allen</a> 043 * @since 3.0.0 044 */ 045@ThreadSafe 046public final class TranslationOptions { 047 @NonNull 048 private static final TranslationOptions NONE; 049 050 static { 051 NONE = new TranslationOptions(null, null, null, null, null, null); 052 } 053 054 @Nullable 055 private final Locale locale; 056 @Nullable 057 private final List<@NonNull LanguageRange> languageRanges; 058 @Nullable 059 private final BidiIsolation bidiIsolation; 060 @Nullable 061 private final TranslationFailureHandler translationFailureHandler; 062 @Nullable 063 private final TranslationFallbackPolicy translationFallbackPolicy; 064 @Nullable 065 private final TranslationFallbackObserver translationFallbackObserver; 066 067 private TranslationOptions(@Nullable Locale locale, 068 @Nullable List<@NonNull LanguageRange> languageRanges, 069 @Nullable BidiIsolation bidiIsolation, 070 @Nullable TranslationFailureHandler translationFailureHandler, 071 @Nullable TranslationFallbackPolicy translationFallbackPolicy, 072 @Nullable TranslationFallbackObserver translationFallbackObserver) { 073 if (locale != null && languageRanges != null) 074 throw new IllegalArgumentException("Specify either locale or languageRanges, not both"); 075 076 this.locale = locale == null ? null : LocaleUtils.requireWellFormed(locale, "Locale override"); 077 this.languageRanges = languageRanges == null ? null : immutableLanguageRanges(languageRanges); 078 this.bidiIsolation = bidiIsolation; 079 this.translationFailureHandler = translationFailureHandler; 080 this.translationFallbackPolicy = translationFallbackPolicy; 081 this.translationFallbackObserver = translationFallbackObserver; 082 } 083 084 /** 085 * Gets options which do not override any {@link Strings} instance defaults. 086 * 087 * @return empty translation options, not null 088 */ 089 @NonNull 090 public static TranslationOptions none() { 091 return NONE; 092 } 093 094 /** 095 * Gets options which use the specified locale for a single lookup. 096 * 097 * @param locale locale to use, not null 098 * @return translation options, not null 099 * @throws IllegalArgumentException if the locale is not well-formed 100 */ 101 @NonNull 102 public static TranslationOptions forLocale(@NonNull Locale locale) { 103 requireNonNull(locale); 104 return builder().locale(locale).build(); 105 } 106 107 /** 108 * Gets options which use the specified language ranges for a single lookup. 109 * 110 * @param languageRanges language ranges to use, not null 111 * @return translation options, not null 112 * @throws IllegalArgumentException if more than {@link LocaleMatcher#MAXIMUM_LANGUAGE_RANGES} language ranges are 113 * supplied 114 */ 115 @NonNull 116 public static TranslationOptions forLanguageRanges(@NonNull List<@NonNull LanguageRange> languageRanges) { 117 requireNonNull(languageRanges); 118 return builder().languageRanges(languageRanges).build(); 119 } 120 121 /** 122 * Creates a translation options builder. 123 * 124 * @return the builder, not null 125 */ 126 @NonNull 127 public static Builder builder() { 128 return new Builder(); 129 } 130 131 /** 132 * Gets the locale override, if configured. 133 * 134 * @return an optional containing the locale override when configured, otherwise empty. not null 135 */ 136 @NonNull 137 public Optional<@NonNull Locale> getLocale() { 138 return Optional.ofNullable(locale); 139 } 140 141 /** 142 * Gets the language-range override, if configured. 143 * 144 * @return an optional containing the language-range override when configured, otherwise empty. not null 145 */ 146 @NonNull 147 public Optional<@NonNull List<@NonNull LanguageRange>> getLanguageRanges() { 148 return Optional.ofNullable(languageRanges); 149 } 150 151 /** 152 * Gets the bidirectional isolation override, if configured. 153 * 154 * @return an optional containing the bidirectional isolation override when configured, otherwise empty. not null 155 */ 156 @NonNull 157 public Optional<@NonNull BidiIsolation> getBidiIsolation() { 158 return Optional.ofNullable(bidiIsolation); 159 } 160 161 /** 162 * Gets the translation failure handler override, if configured. 163 * <p> 164 * The handler may be invoked concurrently when these options are shared and must be thread-safe. 165 * 166 * @return an optional containing the translation failure handler override when configured, otherwise empty. not null 167 */ 168 @NonNull 169 public Optional<@NonNull TranslationFailureHandler> getTranslationFailureHandler() { 170 return Optional.ofNullable(translationFailureHandler); 171 } 172 173 /** 174 * Gets the locale-fallback policy override, if configured. 175 * <p> 176 * The policy may be invoked concurrently when these options are shared and must be thread-safe. 177 * 178 * @return configured fallback policy, or empty, not null 179 */ 180 @NonNull 181 public Optional<@NonNull TranslationFallbackPolicy> getTranslationFallbackPolicy() { 182 return Optional.ofNullable(translationFallbackPolicy); 183 } 184 185 /** 186 * Gets the successful fallback observer override, if configured. 187 * <p> 188 * The observer may be invoked concurrently when these options are shared and must be thread-safe. 189 * 190 * @return configured fallback observer, or empty to use the instance observer, not null 191 * @since 3.1.1 192 */ 193 @NonNull 194 public Optional<@NonNull TranslationFallbackObserver> getTranslationFallbackObserver() { 195 return Optional.ofNullable(translationFallbackObserver); 196 } 197 198 /** 199 * Creates a builder initialized with this instance's values. 200 * 201 * @return the builder, not null 202 */ 203 @NonNull 204 public Builder toBuilder() { 205 return builder() 206 .locale(locale) 207 .languageRanges(languageRanges) 208 .bidiIsolation(bidiIsolation) 209 .translationFailureHandler(translationFailureHandler) 210 .translationFallbackPolicy(translationFallbackPolicy) 211 .translationFallbackObserver(translationFallbackObserver); 212 } 213 214 /** 215 * Generates a {@code String} representation of this object. 216 * 217 * @return a string representation of this object, not null 218 */ 219 @Override 220 @NonNull 221 public String toString() { 222 List<@NonNull String> components = new ArrayList<>(4); 223 224 if (locale != null) 225 components.add("locale=" + locale.toLanguageTag()); 226 227 if (languageRanges != null) 228 components.add("languageRanges=" + languageRanges); 229 230 if (bidiIsolation != null) 231 components.add("bidiIsolation=" + bidiIsolation); 232 233 if (translationFailureHandler != null) 234 components.add("translationFailureHandler=" + translationFailureHandler); 235 236 if (translationFallbackPolicy != null) 237 components.add("translationFallbackPolicy=" + translationFallbackPolicy); 238 239 if (translationFallbackObserver != null) 240 components.add("translationFallbackObserver=" + translationFallbackObserver); 241 242 return Diagnostics.format("%s{%s}", getClass().getSimpleName(), String.join(", ", components)); 243 } 244 245 /** 246 * Checks if this object is equal to another one. 247 * 248 * @param other the object to check, null returns false 249 * @return true if this is equal to the other object, false otherwise 250 */ 251 @Override 252 public boolean equals(@Nullable Object other) { 253 if (this == other) 254 return true; 255 256 if (other == null || !getClass().equals(other.getClass())) 257 return false; 258 259 TranslationOptions translationOptions = (TranslationOptions) other; 260 return Objects.equals(locale, translationOptions.locale) 261 && Objects.equals(languageRanges, translationOptions.languageRanges) 262 && Objects.equals(bidiIsolation, translationOptions.bidiIsolation) 263 && Objects.equals(translationFailureHandler, translationOptions.translationFailureHandler) 264 && Objects.equals(translationFallbackPolicy, translationOptions.translationFallbackPolicy) 265 && Objects.equals(translationFallbackObserver, translationOptions.translationFallbackObserver); 266 } 267 268 /** 269 * A hash code for this object. 270 * 271 * @return a suitable hash code 272 */ 273 @Override 274 public int hashCode() { 275 int hash = Objects.hash(locale, languageRanges, bidiIsolation, translationFailureHandler, translationFallbackPolicy); 276 return translationFallbackObserver == null ? hash : 31 * hash + translationFallbackObserver.hashCode(); 277 } 278 279 @NonNull 280 private static List<@NonNull LanguageRange> immutableLanguageRanges(@NonNull List<@NonNull LanguageRange> languageRanges) { 281 requireNonNull(languageRanges); 282 283 if (languageRanges.size() > LocaleMatcher.MAXIMUM_LANGUAGE_RANGES) 284 throw new IllegalArgumentException(Diagnostics.format("At most %d language ranges are supported, but received %d", 285 LocaleMatcher.MAXIMUM_LANGUAGE_RANGES, languageRanges.size())); 286 287 List<@NonNull LanguageRange> copy = new ArrayList<>(languageRanges.size()); 288 289 for (LanguageRange languageRange : languageRanges) 290 copy.add(requireNonNull(languageRange)); 291 292 return Collections.unmodifiableList(copy); 293 } 294 295 /** 296 * Builder used to construct {@link TranslationOptions} instances. 297 * <p> 298 * This class is intended for use by a single thread. 299 * <p> 300 * Locale and language-range overrides are mutually exclusive. If both setters are used, the last non-null setter wins 301 * and clears the earlier override. 302 * 303 * @since 3.0.0 304 */ 305 @NotThreadSafe 306 public static final class Builder { 307 @Nullable 308 private Locale locale; 309 @Nullable 310 private List<@NonNull LanguageRange> languageRanges; 311 @Nullable 312 private BidiIsolation bidiIsolation; 313 @Nullable 314 private TranslationFailureHandler translationFailureHandler; 315 @Nullable 316 private TranslationFallbackPolicy translationFallbackPolicy; 317 @Nullable 318 private TranslationFallbackObserver translationFallbackObserver; 319 320 private Builder() { 321 // Use TranslationOptions.builder() 322 } 323 324 /** 325 * Applies a locale override. 326 * <p> 327 * Supplying a non-null locale clears any language-range override. 328 * 329 * @param locale locale to use, may be null 330 * @return this builder, not null 331 * @throws IllegalArgumentException if the locale is not well-formed 332 */ 333 @NonNull 334 public Builder locale(@Nullable Locale locale) { 335 this.locale = locale == null ? null : LocaleUtils.requireWellFormed(locale, "Locale override"); 336 337 if (locale != null) 338 this.languageRanges = null; 339 340 return this; 341 } 342 343 /** 344 * Applies a language-range override. 345 * <p> 346 * Supplying non-null language ranges clears any locale override. 347 * 348 * @param languageRanges language ranges to use, may be null 349 * @return this builder, not null 350 * @throws IllegalArgumentException if more than {@link LocaleMatcher#MAXIMUM_LANGUAGE_RANGES} language ranges are 351 * supplied 352 */ 353 @NonNull 354 public Builder languageRanges(@Nullable List<@NonNull LanguageRange> languageRanges) { 355 this.languageRanges = languageRanges == null ? null : immutableLanguageRanges(languageRanges); 356 357 if (languageRanges != null) 358 this.locale = null; 359 360 return this; 361 } 362 363 /** 364 * Applies a bidirectional isolation override. 365 * 366 * @param bidiIsolation bidirectional isolation behavior, may be null 367 * @return this builder, not null 368 */ 369 @NonNull 370 public Builder bidiIsolation(@Nullable BidiIsolation bidiIsolation) { 371 this.bidiIsolation = bidiIsolation; 372 return this; 373 } 374 375 /** 376 * Applies a translation failure handler override. 377 * <p> 378 * The handler may be invoked concurrently after the options are built and must be thread-safe. 379 * 380 * @param translationFailureHandler handler for failed lookups, may be null 381 * @return this builder, not null 382 */ 383 @NonNull 384 public Builder translationFailureHandler(@Nullable TranslationFailureHandler translationFailureHandler) { 385 this.translationFailureHandler = translationFailureHandler; 386 return this; 387 } 388 389 /** 390 * Applies a locale-fallback policy override for this lookup. 391 * <p> 392 * The policy may be invoked concurrently after the options are built and must be thread-safe. 393 * 394 * @param translationFallbackPolicy locale-fallback policy, may be null 395 * @return this builder, not null 396 */ 397 @NonNull 398 public Builder translationFallbackPolicy(@Nullable TranslationFallbackPolicy translationFallbackPolicy) { 399 this.translationFallbackPolicy = translationFallbackPolicy; 400 return this; 401 } 402 403 /** 404 * Applies a successful fallback observer override for this lookup. 405 * <p> 406 * A non-null observer replaces the instance observer. Null means to inherit the instance observer; to suppress 407 * its effects for one lookup, supply a no-op observer. Observers may be invoked concurrently and must be 408 * thread-safe. Observer exceptions propagate directly to the caller. 409 * 410 * @param translationFallbackObserver successful fallback observer, may be null to use the instance observer 411 * @return this builder, not null 412 * @since 3.1.1 413 */ 414 @NonNull 415 public Builder translationFallbackObserver(@Nullable TranslationFallbackObserver translationFallbackObserver) { 416 this.translationFallbackObserver = translationFallbackObserver; 417 return this; 418 } 419 420 /** 421 * Constructs a {@link TranslationOptions} instance. 422 * 423 * @return translation options, not null 424 */ 425 @NonNull 426 public TranslationOptions build() { 427 if (locale == null && languageRanges == null && bidiIsolation == null && translationFailureHandler == null && 428 translationFallbackPolicy == null && translationFallbackObserver == null) 429 return TranslationOptions.none(); 430 431 return new TranslationOptions(locale, languageRanges, bidiIsolation, translationFailureHandler, 432 translationFallbackPolicy, translationFallbackObserver); 433 } 434 } 435}