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 */ 016package com.lokalized; 017 018import org.jspecify.annotations.NonNull; 019import org.jspecify.annotations.Nullable; 020 021import javax.annotation.concurrent.Immutable; 022import java.io.InvalidObjectException; 023import java.io.ObjectInputStream; 024import java.io.Serializable; 025import java.util.ArrayList; 026import java.util.Collections; 027import java.util.LinkedHashSet; 028import java.util.List; 029import java.util.Locale; 030import java.util.Locale.LanguageRange; 031import java.util.Objects; 032import java.util.Optional; 033 034import static com.lokalized.Diagnostics.format; 035import static java.util.Objects.requireNonNull; 036 037/** 038 * Strict, immutable diagnostic result for locale negotiation. 039 * <p> 040 * This value supports Java serialization, including its requested {@link LanguageRange} values. 041 * 042 * @author <a href="https://revetkn.com">Mark Allen</a> 043 * @since 3.0.0 044 */ 045@Immutable 046public final class LocaleMatchResult implements Serializable { 047 private static final long serialVersionUID = 1L; 048 049 /** Language ranges supplied for negotiation, in caller order. */ 050 @NonNull private final List<@NonNull LanguageRange> requestedLanguageRanges; 051 /** Selected supported locale, or null for no match. */ 052 @Nullable private final Locale locale; 053 /** Language range associated with the selection, or null for no match. */ 054 @Nullable private final LanguageRange languageRange; 055 /** Effective quality of the selected locale, or null for no match. */ 056 @Nullable private final Double effectiveWeight; 057 /** Relationship used for the selection. */ 058 @NonNull private final LocaleMatchType matchType; 059 /** Configured fallback locale. */ 060 @NonNull private final Locale fallbackLocale; 061 /** Supported locales considered during negotiation. */ 062 @NonNull private final List<@NonNull Locale> consideredLocales; 063 064 /** 065 * Constructs a locale match result. This is public so custom {@link LocaleMatcher} implementations can expose the 066 * same diagnostics. 067 * 068 * @param requestedLanguageRanges ranges supplied for negotiation, not null 069 * @param locale selected supported locale, or null for no match 070 * @param languageRange range associated with the selection, or null for no match 071 * @param effectiveWeight selected locale's effective quality, or null for no match 072 * @param matchType match relationship, not null 073 * @param fallbackLocale configured fallback locale, not null 074 * @param consideredLocales supported locales considered, not null 075 * @throws IllegalArgumentException if more than {@link LocaleMatcher#MAXIMUM_LANGUAGE_RANGES} requested language 076 * ranges are supplied, any locale is malformed, considered locales have duplicate 077 * language tags, matched and unmatched state is mixed, the effective weight is 078 * outside {@code (0, 1]}, or selected/fallback locales were not considered 079 */ 080 public LocaleMatchResult(@NonNull List<@NonNull LanguageRange> requestedLanguageRanges, 081 @Nullable Locale locale, @Nullable LanguageRange languageRange, @Nullable Double effectiveWeight, 082 @NonNull LocaleMatchType matchType, @NonNull Locale fallbackLocale, 083 @NonNull List<@NonNull Locale> consideredLocales) { 084 requireNonNull(requestedLanguageRanges); 085 086 if (requestedLanguageRanges.size() > LocaleMatcher.MAXIMUM_LANGUAGE_RANGES) 087 throw new IllegalArgumentException(format("At most %d language ranges are supported, but received %d", 088 LocaleMatcher.MAXIMUM_LANGUAGE_RANGES, requestedLanguageRanges.size())); 089 090 List<@NonNull LanguageRange> requestedLanguageRangeCopy = 091 new ArrayList<>(requestedLanguageRanges.size()); 092 093 for (LanguageRange requestedLanguageRange : requestedLanguageRanges) 094 requestedLanguageRangeCopy.add(requireNonNull(requestedLanguageRange)); 095 096 this.requestedLanguageRanges = Collections.unmodifiableList(requestedLanguageRangeCopy); 097 this.locale = locale == null ? null : LocaleUtils.requireWellFormed(locale, "Selected locale"); 098 this.languageRange = languageRange; 099 this.effectiveWeight = effectiveWeight; 100 this.matchType = requireNonNull(matchType); 101 this.fallbackLocale = LocaleUtils.requireWellFormed(fallbackLocale, "Fallback locale"); 102 103 if (locale == null && (languageRange != null || effectiveWeight != null || matchType != LocaleMatchType.NONE)) 104 throw new IllegalArgumentException("An unmatched locale result must use NONE and omit range and weight"); 105 106 if (locale != null && (languageRange == null || effectiveWeight == null || matchType == LocaleMatchType.NONE)) 107 throw new IllegalArgumentException("A matched locale result requires a range, weight, and non-NONE match type"); 108 if (languageRange != null && !requestedLanguageRangeCopy.contains(languageRange)) 109 throw new IllegalArgumentException("The matched language range must be present in requested language ranges"); 110 111 if (effectiveWeight != null && (!Double.isFinite(effectiveWeight) || effectiveWeight <= 0.0 || effectiveWeight > 1.0)) 112 throw new IllegalArgumentException("A matched locale result requires a finite effective weight greater than 0 and at most 1"); 113 List<@NonNull Locale> consideredLocaleCopy = new ArrayList<>(requireNonNull(consideredLocales).size()); 114 LinkedHashSet<@NonNull String> consideredLanguageTags = new LinkedHashSet<>(); 115 116 for (Locale consideredLocale : consideredLocales) { 117 Locale validatedLocale = LocaleUtils.requireWellFormed(consideredLocale, "Considered locale"); 118 String normalizedLanguageTag = validatedLocale.toLanguageTag().toLowerCase(Locale.ROOT); 119 120 if (!consideredLanguageTags.add(normalizedLanguageTag)) 121 throw new IllegalArgumentException(format( 122 "Considered locales must not contain duplicate language tag '%s'", validatedLocale.toLanguageTag())); 123 124 consideredLocaleCopy.add(validatedLocale); 125 } 126 127 if (new LinkedHashSet<>(consideredLocaleCopy).size() != consideredLocaleCopy.size()) 128 throw new IllegalArgumentException("Considered locales must not contain duplicates"); 129 if (!consideredLocaleCopy.contains(fallbackLocale)) 130 throw new IllegalArgumentException("The fallback locale must be present in considered locales"); 131 if (locale != null && !consideredLocaleCopy.contains(locale)) 132 throw new IllegalArgumentException("The selected locale must be present in considered locales"); 133 134 this.consideredLocales = Collections.unmodifiableList(consideredLocaleCopy); 135 } 136 137 /** 138 * Gets the language ranges supplied for negotiation in caller order. 139 * 140 * @return language ranges supplied for negotiation in caller order, not null 141 */ 142 @NonNull 143 public List<@NonNull LanguageRange> getRequestedLanguageRanges() { 144 return requestedLanguageRanges; 145 } 146 147 /** 148 * Gets the selected supported locale. 149 * 150 * @return selected supported locale, or empty when no locale was acceptable, not null 151 */ 152 @NonNull 153 public Optional<@NonNull Locale> getLocale() { 154 return Optional.ofNullable(locale); 155 } 156 157 /** 158 * Gets the preference range associated with the selection. 159 * 160 * @return preference range associated with the selection, or empty for no match, not null 161 */ 162 @NonNull 163 public Optional<@NonNull LanguageRange> getLanguageRange() { 164 return Optional.ofNullable(languageRange); 165 } 166 167 /** 168 * Gets the selected locale's effective preference weight after specific-range overrides. 169 * 170 * @return selected locale's effective preference weight after specific-range overrides, or empty for no match, 171 * not null 172 */ 173 @NonNull 174 public Optional<@NonNull Double> getEffectiveWeight() { 175 return Optional.ofNullable(effectiveWeight); 176 } 177 178 /** 179 * Gets the relationship used for the selection. 180 * 181 * @return relationship used for the selection, or {@link LocaleMatchType#NONE}, not null 182 */ 183 @NonNull 184 public LocaleMatchType getMatchType() { 185 return matchType; 186 } 187 188 /** 189 * Gets the configured fallback that {@link LocaleMatcher#bestMatchFor(List)} would use for no match. 190 * 191 * @return configured fallback that {@link LocaleMatcher#bestMatchFor(List)} would use for no match, not null 192 */ 193 @NonNull 194 public Locale getFallbackLocale() { 195 return fallbackLocale; 196 } 197 198 /** 199 * Gets the ordered supported locales considered by negotiation. 200 * 201 * @return ordered supported locales considered by negotiation, not null 202 */ 203 @NonNull 204 public List<@NonNull Locale> getConsideredLocales() { 205 return consideredLocales; 206 } 207 208 /** 209 * Reports whether negotiation selected an acceptable supported locale. 210 * 211 * @return whether negotiation selected an acceptable supported locale, not null 212 */ 213 @NonNull 214 public Boolean isMatch() { 215 return locale != null; 216 } 217 218 @Override 219 public boolean equals(@Nullable Object object) { 220 if (this == object) 221 return true; 222 if (!(object instanceof LocaleMatchResult)) 223 return false; 224 LocaleMatchResult that = (LocaleMatchResult) object; 225 return Objects.equals(requestedLanguageRanges, that.requestedLanguageRanges) 226 && Objects.equals(locale, that.locale) 227 && Objects.equals(languageRange, that.languageRange) 228 && Objects.equals(effectiveWeight, that.effectiveWeight) 229 && matchType == that.matchType 230 && Objects.equals(fallbackLocale, that.fallbackLocale) 231 && Objects.equals(consideredLocales, that.consideredLocales); 232 } 233 234 @Override 235 public int hashCode() { 236 return Objects.hash(requestedLanguageRanges, locale, languageRange, effectiveWeight, matchType, fallbackLocale, 237 consideredLocales); 238 } 239 240 @Override 241 @NonNull 242 public String toString() { 243 return format("%s{requestedLanguageRanges=%s, locale=%s, languageRange=%s, effectiveWeight=%s, matchType=%s, fallbackLocale=%s, consideredLocales=%s}", 244 getClass().getSimpleName(), requestedLanguageRanges, locale, languageRange, effectiveWeight, matchType, 245 fallbackLocale, consideredLocales); 246 } 247 248 /** 249 * Replaces this value with a proxy that serializes language ranges as range/weight pairs. 250 * 251 * @return serialization proxy, not null 252 */ 253 private Object writeReplace() { 254 return new SerializationProxy(this); 255 } 256 257 /** 258 * Rejects direct deserialization so construction always passes through the validating proxy. 259 * 260 * @param inputStream serialized input, not null 261 * @throws InvalidObjectException always 262 */ 263 private void readObject(ObjectInputStream inputStream) throws InvalidObjectException { 264 throw new InvalidObjectException("Serialization proxy required"); 265 } 266 267 private static final class SerializationProxy implements Serializable { 268 private static final long serialVersionUID = 1L; 269 270 @NonNull private final String[] requestedLanguageRanges; 271 @NonNull private final double[] requestedLanguageRangeWeights; 272 @Nullable private final Locale locale; 273 private final int languageRangeIndex; 274 @Nullable private final Double effectiveWeight; 275 @NonNull private final LocaleMatchType matchType; 276 @NonNull private final Locale fallbackLocale; 277 @NonNull private final Locale[] consideredLocales; 278 279 private SerializationProxy(@NonNull LocaleMatchResult localeMatchResult) { 280 int requestedLanguageRangeCount = localeMatchResult.requestedLanguageRanges.size(); 281 this.requestedLanguageRanges = new String[requestedLanguageRangeCount]; 282 this.requestedLanguageRangeWeights = new double[requestedLanguageRangeCount]; 283 284 for (int index = 0; index < requestedLanguageRangeCount; ++index) { 285 LanguageRange requestedLanguageRange = localeMatchResult.requestedLanguageRanges.get(index); 286 this.requestedLanguageRanges[index] = requestedLanguageRange.getRange(); 287 this.requestedLanguageRangeWeights[index] = requestedLanguageRange.getWeight(); 288 } 289 290 this.locale = localeMatchResult.locale; 291 this.languageRangeIndex = localeMatchResult.languageRange == null ? -1 292 : localeMatchResult.requestedLanguageRanges.indexOf(localeMatchResult.languageRange); 293 this.effectiveWeight = localeMatchResult.effectiveWeight; 294 this.matchType = localeMatchResult.matchType; 295 this.fallbackLocale = localeMatchResult.fallbackLocale; 296 this.consideredLocales = localeMatchResult.consideredLocales.toArray(new Locale[0]); 297 } 298 299 private Object readResolve() throws InvalidObjectException { 300 try { 301 if (requestedLanguageRanges.length != requestedLanguageRangeWeights.length) 302 throw new IllegalArgumentException("Language range values and weights must have equal lengths"); 303 304 List<@NonNull LanguageRange> reconstructedLanguageRanges = 305 new ArrayList<>(requestedLanguageRanges.length); 306 307 for (int index = 0; index < requestedLanguageRanges.length; ++index) 308 reconstructedLanguageRanges.add( 309 new LanguageRange(requestedLanguageRanges[index], requestedLanguageRangeWeights[index])); 310 311 LanguageRange reconstructedLanguageRange = null; 312 313 if (languageRangeIndex < -1 || languageRangeIndex >= reconstructedLanguageRanges.size()) 314 throw new IllegalArgumentException("Matched language range index is outside the requested ranges"); 315 if (languageRangeIndex >= 0) 316 reconstructedLanguageRange = reconstructedLanguageRanges.get(languageRangeIndex); 317 318 return new LocaleMatchResult(reconstructedLanguageRanges, locale, reconstructedLanguageRange, 319 effectiveWeight, matchType, fallbackLocale, List.of(consideredLocales)); 320 } catch (RuntimeException exception) { 321 InvalidObjectException invalidObjectException = 322 new InvalidObjectException("Invalid serialized LocaleMatchResult"); 323 invalidObjectException.initCause(exception); 324 throw invalidObjectException; 325 } 326 } 327 } 328}