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.ThreadSafe;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.LinkedHashSet;
026import java.util.List;
027import java.util.Locale;
028import java.util.Optional;
029
030import static com.lokalized.Diagnostics.format;
031import static java.util.Objects.requireNonNull;
032
033/**
034 * Diagnostic result for a translation lookup. Instances are structurally immutable and safe to share. Collection
035 * state is defensively copied; an optional failure cause is exposed by reference and is outside this class's
036 * thread-safety guarantee.
037 * <p>
038 * If evaluating a reachable {@link LocalizedString.ExpressionAlternative expression-fragment predicate} or
039 * interpolating its selected/default fragment fails, a handler-produced result reports
040 * {@link TranslationFailureReason#RESOLUTION_FAILURE} and exposes the runtime cause. The
041 * {@link TranslationFallbackPolicy#fallbackOnMissingTranslationOrNoMatchingAlternative() default fallback policy}
042 * stops at that failure, while
043 * {@link TranslationFallbackPolicy#fallbackOnAnyFailure()} may first try another locale. Expression-selected fragments
044 * have required defaults and therefore cannot themselves produce
045 * {@link TranslationFailureReason#NO_MATCHING_ALTERNATIVE}.
046 *
047 * @author <a href="https://revetkn.com">Mark Allen</a>
048 * @since 3.0.0
049 */
050@ThreadSafe
051public final class TranslationResult {
052        @NonNull private final String key;
053        @NonNull private final String translation;
054        @NonNull private final Locale lookupLocale;
055        @Nullable private final LocaleMatchResult localeMatchResult;
056        @Nullable private final Locale resolvedLocale;
057        @NonNull private final List<@NonNull Locale> attemptedLocales;
058        @NonNull private final TranslationResultStatus status;
059        @Nullable private final TranslationFailureReason failureReason;
060        @Nullable private final Throwable cause;
061
062        /**
063         * Constructs a translation result. This is public so custom {@link Strings} implementations can participate in the
064         * diagnostic result API.
065         *
066         * @param key translation key, not null
067         * @param translation returned string, not null
068         * @param lookupLocale locale used to begin per-key locale fallback, not null
069         * @param resolvedLocale successfully resolved locale, or null for a failure-handler response
070         * @param attemptedLocales ordered locales actually attempted, not null
071         * @param status result status, not null
072         * @param failureReason final failure reason for a handler response, otherwise null
073         * @param cause first runtime resolution cause, including an evaluated expression-fragment or selected/default
074         *              fragment failure, otherwise null
075         * @throws IllegalArgumentException if any locale is malformed, success and failure state is mixed, a resolution
076         *                                  cause and reason disagree, attempted locales contain duplicates, or the resolved
077         *                                  locale was not attempted
078         */
079        public TranslationResult(@NonNull String key, @NonNull String translation, @NonNull Locale lookupLocale,
080                                                                @Nullable Locale resolvedLocale, @NonNull List<@NonNull Locale> attemptedLocales,
081                                                                @NonNull TranslationResultStatus status, @Nullable TranslationFailureReason failureReason,
082                                                                @Nullable Throwable cause) {
083                this(key, translation, lookupLocale, null, resolvedLocale, attemptedLocales, status, failureReason, cause);
084        }
085
086        /**
087         * Constructs a translation result with locale-negotiation diagnostics.
088         *
089         * @param key translation key, not null
090         * @param translation returned string, not null
091         * @param lookupLocale locale used to begin per-key locale fallback, not null
092         * @param localeMatchResult strict locale-negotiation result, or null when unavailable
093         * @param resolvedLocale successfully resolved locale, or null for a failure-handler response
094         * @param attemptedLocales ordered locales actually attempted, not null
095         * @param status result status, not null
096         * @param failureReason final failure reason for a handler response, otherwise null
097         * @param cause runtime resolution cause, including an evaluated expression-fragment or selected/default fragment
098         *              failure, otherwise null
099         * @throws IllegalArgumentException if any locale is malformed, success and failure state is mixed, a resolution
100         *                                  cause and reason disagree, attempted locales contain duplicates, or the resolved
101         *                                  locale was not attempted
102         */
103        public TranslationResult(@NonNull String key, @NonNull String translation, @NonNull Locale lookupLocale,
104                                                                @Nullable LocaleMatchResult localeMatchResult, @Nullable Locale resolvedLocale,
105                                                                @NonNull List<@NonNull Locale> attemptedLocales, @NonNull TranslationResultStatus status,
106                                                                @Nullable TranslationFailureReason failureReason, @Nullable Throwable cause) {
107                this.key = requireNonNull(key);
108                this.translation = requireNonNull(translation);
109                this.lookupLocale = LocaleUtils.requireWellFormed(lookupLocale, "Lookup locale");
110                this.localeMatchResult = localeMatchResult;
111                this.resolvedLocale = resolvedLocale == null ? null : LocaleUtils.requireWellFormed(resolvedLocale, "Resolved locale");
112                List<@NonNull Locale> attemptedLocaleCopy = new ArrayList<>(requireNonNull(attemptedLocales).size());
113                LinkedHashSet<@NonNull String> attemptedLanguageTags = new LinkedHashSet<>();
114
115                for (Locale attemptedLocale : attemptedLocales) {
116                        Locale validatedLocale = LocaleUtils.requireWellFormed(attemptedLocale, "Attempted locale");
117                        String normalizedLanguageTag = validatedLocale.toLanguageTag().toLowerCase(Locale.ROOT);
118
119                        if (!attemptedLanguageTags.add(normalizedLanguageTag))
120                                throw new IllegalArgumentException(format(
121                                                "Attempted locales must not contain duplicate language tag '%s'", validatedLocale.toLanguageTag()));
122
123                        attemptedLocaleCopy.add(validatedLocale);
124                }
125
126                if (new LinkedHashSet<>(attemptedLocaleCopy).size() != attemptedLocaleCopy.size())
127                        throw new IllegalArgumentException("Attempted locales must not contain duplicates");
128
129                this.attemptedLocales = Collections.unmodifiableList(attemptedLocaleCopy);
130                this.status = requireNonNull(status);
131                this.failureReason = failureReason;
132                this.cause = cause;
133
134                if (status == TranslationResultStatus.TRANSLATED &&
135                                (resolvedLocale == null || failureReason != null || cause != null))
136                        throw new IllegalArgumentException("A translated result requires a resolved locale and no failure outcome");
137
138                if (status != TranslationResultStatus.TRANSLATED && (resolvedLocale != null || failureReason == null))
139                        throw new IllegalArgumentException("A failure-handler result requires a failure reason and no resolved locale");
140
141                if (status == TranslationResultStatus.TRANSLATED && !attemptedLocaleCopy.contains(resolvedLocale))
142                        throw new IllegalArgumentException("A translated result's resolved locale must be present in attempted locales");
143
144                if (status != TranslationResultStatus.TRANSLATED &&
145                                ((failureReason == TranslationFailureReason.RESOLUTION_FAILURE) != (cause != null)))
146                        throw new IllegalArgumentException("A failure result must carry a cause if and only if its reason is RESOLUTION_FAILURE");
147        }
148
149        /**
150         * Gets the translation key.
151         *
152         * @return translation key, not null
153         */
154        @NonNull
155        public String getKey() {
156                return key;
157        }
158
159        /**
160         * Gets the returned translation, key, or handler-supplied string.
161         *
162         * @return returned translation, key, or handler-supplied string, not null
163         */
164        @NonNull
165        public String getTranslation() {
166                return translation;
167        }
168
169        /**
170         * Gets the locale used to begin per-key locale fallback after any negotiation.
171         *
172         * @return locale used to begin per-key locale fallback after any negotiation, not null
173         */
174        @NonNull
175        public Locale getLookupLocale() {
176                return lookupLocale;
177        }
178
179        /**
180         * Gets strict locale-negotiation diagnostics when available.
181         *
182         * @return strict locale-negotiation diagnostics when available, otherwise empty, not null
183         */
184        @NonNull
185        public Optional<@NonNull LocaleMatchResult> getLocaleMatchResult() {
186                return Optional.ofNullable(localeMatchResult);
187        }
188
189        /**
190         * Gets the locale that resolved successfully.
191         *
192         * @return locale that resolved successfully, or empty for a failure response, not null
193         */
194        @NonNull
195        public Optional<@NonNull Locale> getResolvedLocale() {
196                return Optional.ofNullable(resolvedLocale);
197        }
198
199        /**
200         * Gets the ordered locales actually attempted.
201         *
202         * @return ordered locales actually attempted, not null
203         */
204        @NonNull
205        public List<@NonNull Locale> getAttemptedLocales() {
206                return attemptedLocales;
207        }
208
209        /**
210         * Gets how the returned string was produced.
211         *
212         * @return how the returned string was produced, not null
213         */
214        @NonNull
215        public TranslationResultStatus getStatus() {
216                return status;
217        }
218
219        /**
220         * Gets the final lookup failure reason for handler-produced results.
221         * <p>
222         * Evaluated expression-fragment predicate and selected/default fragment failures are
223         * {@link TranslationFailureReason#RESOLUTION_FAILURE}; an expression-selected fragment cannot itself produce
224         * {@link TranslationFailureReason#NO_MATCHING_ALTERNATIVE} because it has a required default.
225         *
226         * @return final lookup failure reason for handler-produced results, otherwise empty, not null
227         */
228        @NonNull
229        public Optional<@NonNull TranslationFailureReason> getFailureReason() {
230                return Optional.ofNullable(failureReason);
231        }
232
233        /**
234         * Gets the first runtime resolution cause for a failed lookup.
235         *
236         * @return first runtime resolution cause for a failed lookup, including an evaluated expression-fragment or
237         * selected/default fragment failure, otherwise empty, not null
238         */
239        @NonNull
240        public Optional<@NonNull Throwable> getCause() {
241                return Optional.ofNullable(cause);
242        }
243
244        /**
245         * Reports whether negotiation or per-key resolution used a fallback.
246         *
247         * @return whether negotiation produced no match, used CLDR parent, likely-subtag, or primary-language fallback, or
248         * per-key resolution used a non-equivalent locale, not null
249         */
250        @NonNull
251        public Boolean isFallback() {
252                return negotiationUsedFallback()
253                                || (resolvedLocale != null && !CldrLocaleData.equivalent(lookupLocale, resolvedLocale));
254        }
255
256        private boolean negotiationUsedFallback() {
257                if (localeMatchResult == null)
258                        return false;
259
260                switch (localeMatchResult.getMatchType()) {
261                        case NONE:
262                        case CLDR_FALLBACK:
263                        case LIKELY_SUBTAG:
264                        case PRIMARY_LANGUAGE:
265                                return true;
266                        case EXACT:
267                        case CANONICAL:
268                        case EXTENDED_RANGE:
269                        case WILDCARD:
270                                return false;
271                        default:
272                                throw new IllegalArgumentException(format("Unsupported locale match type %s",
273                                                localeMatchResult.getMatchType()));
274                }
275        }
276
277        /**
278         * Generates a diagnostic representation without rendering translation contents or throwable messages.
279         *
280         * @return a safe diagnostic representation, not null
281         */
282        @Override
283        @NonNull
284        public String toString() {
285                return format("%s{key=%s, translationLength=%d, lookupLocale=%s, localeMatchResult=%s, resolvedLocale=%s, " +
286                                "attemptedLocales=%s, status=%s, failureReason=%s, causeType=%s}", getClass().getSimpleName(), key,
287                                translation.length(), lookupLocale, localeMatchResult, resolvedLocale, attemptedLocales, status, failureReason,
288                                cause == null ? null : cause.getClass().getName());
289        }
290}