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}