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}