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}