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}