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}