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.List; 026import java.util.Locale; 027import java.util.Optional; 028 029import static java.util.Objects.requireNonNull; 030 031/** 032 * Diagnostic event for a lookup whose later locale candidate supplied a translation. 033 * <p> 034 * The event records every preceding candidate's own failure reason and cause, in attempt order. It contains no 035 * rendered translation or caller placeholder values. Collection state is unmodifiable; runtime causes are exposed 036 * by reference and are outside this class's thread-safety guarantee. 037 * 038 * @author <a href="https://revetkn.com">Mark Allen</a> 039 * @since 3.1.1 040 */ 041@ThreadSafe 042public final class TranslationFallbackEvent { 043 @NonNull private final String key; 044 @NonNull private final Locale lookupLocale; 045 @Nullable private final LocaleMatchResult localeMatchResult; 046 @NonNull private final List<@NonNull Locale> attemptedLocales; 047 @NonNull private final Locale resolvedLocale; 048 @NonNull private final List<@NonNull PrecedingFailure> precedingFailures; 049 050 /** 051 * Constructs an event from a successful result and the failures preceding it. Custom {@link Strings} 052 * implementations can use this constructor to expose the same diagnostics. 053 * <p> 054 * The locale-match result and unmodifiable attempted-locales list are shared with {@code translationResult}. The supplied 055 * failure list is defensively copied. This event does not retain the rendered translation from {@code translationResult}. 056 * 057 * @param translationResult a translated result whose successful locale follows at least one failed candidate, not null 058 * @param precedingFailures failures for every preceding candidate, in attempt order, not null 059 * @throws IllegalArgumentException if the result is not translated, there are no preceding candidates, or the 060 * failures do not correspond exactly to the preceding attempted locales 061 */ 062 public TranslationFallbackEvent(@NonNull TranslationResult translationResult, 063 @NonNull List<@NonNull PrecedingFailure> precedingFailures) { 064 requireNonNull(translationResult); 065 requireNonNull(precedingFailures); 066 067 if (translationResult.getStatus() != TranslationResultStatus.TRANSLATED) 068 throw new IllegalArgumentException("A fallback event requires a translated result"); 069 070 List<@NonNull Locale> attemptedLocales = translationResult.getAttemptedLocales(); 071 072 if (precedingFailures.isEmpty() || precedingFailures.size() != attemptedLocales.size() - 1) 073 throw new IllegalArgumentException("A fallback event requires one failure for each preceding locale candidate"); 074 075 Locale resolvedLocale = translationResult.getResolvedLocale().orElseThrow(IllegalArgumentException::new); 076 077 if (!resolvedLocale.equals(attemptedLocales.get(attemptedLocales.size() - 1))) 078 throw new IllegalArgumentException("The final attempted locale must supply the fallback translation"); 079 080 List<@NonNull PrecedingFailure> precedingFailuresCopy = new ArrayList<>(precedingFailures.size()); 081 082 for (int index = 0; index < precedingFailures.size(); ++index) { 083 PrecedingFailure precedingFailure = requireNonNull(precedingFailures.get(index)); 084 085 if (!precedingFailure.getLocale().equals(attemptedLocales.get(index))) 086 throw new IllegalArgumentException("Preceding failures must follow the attempted locale order"); 087 088 precedingFailuresCopy.add(precedingFailure); 089 } 090 091 this.key = translationResult.getKey(); 092 this.lookupLocale = translationResult.getLookupLocale(); 093 this.localeMatchResult = translationResult.getLocaleMatchResult().orElse(null); 094 this.attemptedLocales = attemptedLocales; 095 this.resolvedLocale = resolvedLocale; 096 this.precedingFailures = Collections.unmodifiableList(precedingFailuresCopy); 097 } 098 099 /** 100 * Gets the translation key. 101 * 102 * @return translation key, not null 103 */ 104 @NonNull 105 public String getKey() { 106 return key; 107 } 108 109 /** 110 * Gets the locale used to begin per-key lookup. 111 * 112 * @return lookup locale, not null 113 */ 114 @NonNull 115 public Locale getLookupLocale() { 116 return lookupLocale; 117 } 118 119 /** 120 * Gets the locale-negotiation diagnostics when available. 121 * 122 * @return locale-match result when available, otherwise empty, not null 123 */ 124 @NonNull 125 public Optional<@NonNull LocaleMatchResult> getLocaleMatchResult() { 126 return Optional.ofNullable(localeMatchResult); 127 } 128 129 /** 130 * Gets all attempted locales through the successful candidate. 131 * 132 * @return attempted locales in attempt order, not null 133 */ 134 @NonNull 135 public List<@NonNull Locale> getAttemptedLocales() { 136 return attemptedLocales; 137 } 138 139 /** 140 * Gets the locale that supplied the translation. 141 * 142 * @return resolved locale, not null 143 */ 144 @NonNull 145 public Locale getResolvedLocale() { 146 return resolvedLocale; 147 } 148 149 /** 150 * Gets the failures preceding the successful candidate. 151 * 152 * @return preceding failures in attempt order, not null 153 */ 154 @NonNull 155 public List<@NonNull PrecedingFailure> getPrecedingFailures() { 156 return precedingFailures; 157 } 158 159 /** @return diagnostic representation omitting translation text and throwable messages, not null */ 160 @Override 161 @NonNull 162 public String toString() { 163 return Diagnostics.format("%s{key='%s', lookupLocale=%s, resolvedLocale=%s, precedingFailures=%s}", 164 getClass().getSimpleName(), key, lookupLocale.toLanguageTag(), resolvedLocale.toLanguageTag(), precedingFailures); 165 } 166 167 /** 168 * Failure of one locale candidate preceding a successful fallback translation. 169 * <p> 170 * The runtime cause is exposed by reference and is outside this class's thread-safety guarantee. 171 * 172 * @since 3.1.1 173 */ 174 @ThreadSafe 175 public static final class PrecedingFailure { 176 @NonNull private final Locale locale; 177 @NonNull private final TranslationFailureReason reason; 178 @Nullable private final Throwable cause; 179 180 /** 181 * Constructs a preceding candidate failure. 182 * 183 * @param locale attempted locale, not null 184 * @param reason reason the candidate failed, not null 185 * @param cause runtime cause for a resolution failure, otherwise null 186 * @throws IllegalArgumentException if the locale is malformed, a resolution failure has no cause, or another 187 * failure reason has a cause 188 */ 189 public PrecedingFailure(@NonNull Locale locale, @NonNull TranslationFailureReason reason, @Nullable Throwable cause) { 190 this.locale = LocaleUtils.requireWellFormed(locale, "Attempted locale"); 191 this.reason = requireNonNull(reason); 192 193 if ((reason == TranslationFailureReason.RESOLUTION_FAILURE) != (cause != null)) 194 throw new IllegalArgumentException("A preceding failure must carry a cause if and only if its reason is RESOLUTION_FAILURE"); 195 196 this.cause = cause; 197 } 198 199 /** 200 * Gets the attempted locale. 201 * 202 * @return attempted locale, not null 203 */ 204 @NonNull 205 public Locale getLocale() { 206 return locale; 207 } 208 209 /** 210 * Gets the reason this candidate failed. 211 * 212 * @return failure reason, not null 213 */ 214 @NonNull 215 public TranslationFailureReason getReason() { 216 return reason; 217 } 218 219 /** 220 * Gets this candidate's runtime cause when present. 221 * 222 * @return runtime cause when present, otherwise empty, not null 223 */ 224 @NonNull 225 public Optional<@NonNull Throwable> getCause() { 226 return Optional.ofNullable(cause); 227 } 228 229 /** @return diagnostic representation omitting throwable messages, not null */ 230 @Override 231 @NonNull 232 public String toString() { 233 return Diagnostics.format("%s{locale=%s, reason=%s}", getClass().getSimpleName(), locale.toLanguageTag(), reason); 234 } 235 } 236}