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