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 java.util.List;
023import java.util.Locale;
024import java.util.Locale.LanguageRange;
025
026/**
027 * Contract for matching an input {@link Locale} or {@link List}{@code <}{@link LanguageRange}{@code >} to an appropriate localized strings {@link Locale}.
028 * <p>
029 * Lokalized's implementation prefers exact and CLDR-canonical matches, then CLDR parent-locale fallback,
030 * then script-aware likely-subtag matches. If multiple supported localized strings files still share the same language,
031 * configured tiebreakers determine which locale wins. Unmatched, root, and undetermined requests resolve to the
032 * configured fallback locale when using {@code bestMatchFor(...)}. The strict {@code matchFor(...)} methods represent
033 * the same state as an unmatched {@link LocaleMatchResult} instead of manufacturing a match.
034 * <p>
035 * Non-bare wildcard language ranges use RFC 4647 extended filtering only; they are not broadened through
036 * CLDR, likely-subtag, or primary-language heuristics, and successful results report
037 * {@link LocaleMatchType#EXTENDED_RANGE}. When multiple ranges match one supported locale, the most-specific exact,
038 * canonical, or structural range determines its effective quality, including a {@code q=0} exclusion.
039 *
040 * @author <a href="https://revetkn.com">Mark Allen</a>
041 */
042public interface LocaleMatcher {
043        /**
044         * Maximum number of parsed language ranges accepted by one matching operation: 32.
045         * <p>
046         * {@link LanguageRange#parse(String)} may add IANA-equivalent ranges, so this limit applies to the returned list,
047         * not the number of comma-separated ranges in the source header.
048         *
049         * @since 3.0.0
050         */
051        @NonNull
052        public static final Integer MAXIMUM_LANGUAGE_RANGES = 32;
053
054        /**
055         * Strictly negotiates a locale without manufacturing a configured-fallback match.
056         *
057         * @param locale requested locale, not null
058         * @return diagnostic match result, not null
059         * @throws IllegalArgumentException if the locale is not well-formed
060         * @since 3.0.0
061         */
062        @NonNull
063        default LocaleMatchResult matchFor(@NonNull Locale locale) {
064                LocaleUtils.requireWellFormed(locale, "Requested locale");
065                return matchFor(List.of(new LanguageRange(locale.toLanguageTag())));
066        }
067
068        /**
069         * Strictly negotiates language ranges without manufacturing a configured-fallback match.
070         *
071         * @param languageRanges requested language ranges, not null
072         * @return diagnostic match result, not null
073         * @throws IllegalArgumentException if more than {@link #MAXIMUM_LANGUAGE_RANGES} language ranges are supplied
074         * @since 3.0.0
075         */
076        @NonNull
077        LocaleMatchResult matchFor(@NonNull List<@NonNull LanguageRange> languageRanges);
078
079        /**
080         * Given a locale, determine the best-matching localized strings file's locale.
081         *
082         * @param locale the locale for which to find the best match.
083         * @return the best-matching locale, not null
084         * @throws IllegalArgumentException if the locale is not well-formed
085         */
086        @NonNull
087        Locale bestMatchFor(@NonNull Locale locale);
088
089        /**
090         * Given a list of language ranges (e.g. as parsed from an {@code Accept-Language} HTTP request header), determine the best-matching localized strings file's locale.
091         *
092         * @param languageRanges the ordered list of language ranges for which to find the best match.
093         * @return the best-matching locale, not null
094         * @throws IllegalArgumentException if more than {@link #MAXIMUM_LANGUAGE_RANGES} language ranges are supplied
095         */
096        @NonNull
097        Locale bestMatchFor(@NonNull List<@NonNull LanguageRange> languageRanges);
098
099        /**
100         * Given a raw {@code Accept-Language} HTTP field value, determines the best-matching localized strings
101         * file's locale.
102         * <p>
103         * This is a fail-soft convenience for request handling. A missing, blank, malformed, or longer than 4,096 UTF-16
104         * code-unit value returns the configured fallback locale. The length limit is applied before parsing so parser work
105         * is bounded independently of the parsed-range limit. HTTP optional whitespace and empty list elements are
106         * normalized before parsing. The configured fallback is also returned if parsing produces more than
107         * {@link #MAXIMUM_LANGUAGE_RANGES} ranges, which can happen when {@link LanguageRange#parse(String)} adds
108         * IANA-equivalent ranges. A valid parsed list is passed through whole; preferences are never truncated. Use
109         * {@link #matchFor(List)} or {@link #bestMatchFor(List)} when language ranges have already been parsed and strict
110         * limit enforcement is desired.
111         *
112         * @param acceptLanguage raw, already-combined {@code Accept-Language} field value, or null if absent
113         * @return the best-matching locale, or the configured fallback for unusable input, not null
114         * @since 3.0.0
115         */
116        @NonNull
117        default Locale bestMatchForAcceptLanguage(@Nullable String acceptLanguage) {
118                if (acceptLanguage == null ||
119                                acceptLanguage.length() > 4_096 ||
120                                acceptLanguage.trim().isEmpty())
121                        return bestMatchFor(List.of());
122
123                String normalizedAcceptLanguage = normalizeAcceptLanguage(acceptLanguage);
124
125                if (normalizedAcceptLanguage.isEmpty())
126                        return bestMatchFor(List.of());
127
128                List<@NonNull LanguageRange> languageRanges;
129
130                try {
131                        languageRanges = LanguageRange.parse(normalizedAcceptLanguage);
132                } catch (IllegalArgumentException | IndexOutOfBoundsException exception) {
133                        return bestMatchFor(List.of());
134                }
135
136                if (languageRanges.size() > MAXIMUM_LANGUAGE_RANGES)
137                        return bestMatchFor(List.of());
138
139                return bestMatchFor(languageRanges);
140        }
141
142        /**
143         * Converts RFC 9110 horizontal-tab whitespace to the space form understood by
144         * {@link LanguageRange#parse(String)} and removes empty HTTP list elements.
145         */
146        @NonNull
147        private static String normalizeAcceptLanguage(@NonNull String acceptLanguage) {
148                StringBuilder normalizedAcceptLanguage = new StringBuilder(acceptLanguage.length());
149                int memberStartIndex = 0;
150
151                for (int index = 0; index <= acceptLanguage.length(); ++index) {
152                        if (index < acceptLanguage.length() && acceptLanguage.charAt(index) != ',')
153                                continue;
154
155                        int firstContentIndex = memberStartIndex;
156
157                        while (firstContentIndex < index && isOptionalWhitespace(acceptLanguage.charAt(firstContentIndex)))
158                                ++firstContentIndex;
159
160                        int contentEndIndex = index;
161
162                        while (contentEndIndex > firstContentIndex &&
163                                        isOptionalWhitespace(acceptLanguage.charAt(contentEndIndex - 1)))
164                                --contentEndIndex;
165
166                        if (firstContentIndex < contentEndIndex) {
167                                if (normalizedAcceptLanguage.length() > 0)
168                                        normalizedAcceptLanguage.append(',');
169
170                                for (int contentIndex = firstContentIndex; contentIndex < contentEndIndex; ++contentIndex) {
171                                        char character = acceptLanguage.charAt(contentIndex);
172                                        normalizedAcceptLanguage.append(character == '\t' ? ' ' : character);
173                                }
174                        }
175
176                        memberStartIndex = index + 1;
177                }
178
179                return normalizedAcceptLanguage.toString();
180        }
181
182        private static boolean isOptionalWhitespace(char character) {
183                return character == ' ' || character == '\t';
184        }
185}