001// Copyright 2006, 2007, 2009, 2010, 2012, 2026 The Apache Software Foundation
002//
003// Licensed under the Apache License, Version 2.0 (the "License");
004// you may not use this file except in compliance with the License.
005// You may obtain a copy of the License at
006//
007// http://www.apache.org/licenses/LICENSE-2.0
008//
009// Unless required by applicable law or agreed to in writing, software
010// distributed under the License is distributed on an "AS IS" BASIS,
011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012// See the License for the specific language governing permissions and
013// limitations under the License.
014
015package org.apache.tapestry5.internal.services;
016
017import java.util.Collections;
018import java.util.List;
019import java.util.Locale;
020import java.util.Map;
021import java.util.Set;
022
023import org.apache.tapestry5.OptionModel;
024import org.apache.tapestry5.SelectModel;
025import org.apache.tapestry5.SymbolConstants;
026import org.apache.tapestry5.commons.util.CollectionFactory;
027import org.apache.tapestry5.http.services.Request;
028import org.apache.tapestry5.internal.OptionModelImpl;
029import org.apache.tapestry5.internal.SelectModelImpl;
030import org.apache.tapestry5.internal.TapestryInternalUtils;
031import org.apache.tapestry5.ioc.annotations.Symbol;
032import org.apache.tapestry5.ioc.services.ThreadLocale;
033import org.apache.tapestry5.services.LocalizationSetter;
034import org.apache.tapestry5.services.PersistentLocale;
035
036/**
037 * Given a set of supported locales, for a specified desired locale, sets the current thread's locale to a supported
038 * locale that is closest to the desired.
039 */
040public class LocalizationSetterImpl implements LocalizationSetter
041{
042    /**
043     * Upper bound on the number of entries retained in {@link #localeCache}.
044     *
045     * <p>
046     * The cache lives for the lifetime of this (singleton) service.
047     * The configured supported locales are pre-seeded and never evicted, so this budget only applies
048     * to request-derived names.
049     * </p>
050     */
051    static final int MAX_LOCALE_CACHE_SIZE = 1000;
052
053    /**
054     * Maximum length of a single underscore-separated term (language, country or variant) of a
055     * cacheable locale name.
056     */
057    static final int MAX_TERM_LENGTH = 16;
058
059    private final Request request;
060
061    private final ThreadLocale threadLocale;
062
063    private final Locale defaultLocale;
064
065    private final Set<String> supportedLocaleNames;
066
067    private final List<Locale> supportedLocales;
068
069    private final Map<String, Locale> localeCache = CollectionFactory.newConcurrentMap();
070
071    private final PersistentLocale persistentLocale;
072
073    public LocalizationSetterImpl(Request request,
074
075    PersistentLocale persistentLocale,
076
077    ThreadLocale threadLocale,
078
079    @Symbol(SymbolConstants.SUPPORTED_LOCALES)
080    String localeNames)
081    {
082        this.request = request;
083
084        this.persistentLocale = persistentLocale;
085        this.threadLocale = threadLocale;
086
087        this.supportedLocaleNames = CollectionFactory.newSet();
088        
089        String[] names = TapestryInternalUtils.splitAtCommas(localeNames);
090        
091        for (String name : names)
092        {
093            supportedLocaleNames.add(name.toLowerCase());
094        }
095
096        supportedLocales = parseNames(names);
097
098        defaultLocale = supportedLocales.get(0);
099    }
100
101    private List<Locale> parseNames(String[] localeNames)
102    {
103        List<Locale> list = CollectionFactory.newList();
104
105        for (String name : localeNames)
106        {
107            Locale locale = constructLocale(name);
108
109            // Pre-seed the cache with the supported locales.
110            // They are trusted configuration and the hot resolution target.
111            localeCache.put(name, locale);
112
113            list.add(locale);
114        }
115
116        return Collections.unmodifiableList(list);
117    }
118
119    public Locale toLocale(String localeName)
120    {
121        Locale result = localeCache.get(localeName);
122
123        if (result == null)
124        {
125            result = constructLocale(localeName);
126
127            if (isCacheableLocaleName(localeName) && localeCache.size() < MAX_LOCALE_CACHE_SIZE)
128            {
129                localeCache.put(localeName, result);
130            }
131        }
132
133        return result;
134    }
135
136    /**
137     * Whether a locale name is structurally plausible enough to be worth storing in the long-lived
138     * {@link #localeCache}.
139     *
140     * <p>This is a deliberately strict, cheap guard, instead of a full full locale validation:
141     * at most three underscore-separated terms, each ASCII-alphanumeric and bounded by
142     * {@link #MAX_TERM_LENGTH}.
143     */
144    static boolean isCacheableLocaleName(String localeName)
145    {
146        final int length = localeName.length();
147
148        if (length == 0)
149        {
150            return false;
151        }
152
153        int terms = 1;
154        int termLength = 0;
155
156        for (int i = 0; i < length; i++)
157        {
158            final char c = localeName.charAt(i);
159
160            if (c == '_')
161            {
162                // matches constructLocale()'s own limit of three terms
163                if (++terms > 3)
164                {
165                    return false;
166                }
167                termLength = 0;
168            }
169            else if (!isAsciiLetterOrDigit(c) || ++termLength > MAX_TERM_LENGTH)
170            {
171                return false;
172            }
173        }
174
175        return true;
176    }
177
178    private static boolean isAsciiLetterOrDigit(char c)
179    {
180        return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
181    }
182
183    private Locale constructLocale(String name)
184    {
185        String[] terms = name.split("_");
186
187        switch (terms.length)
188        {
189            case 1:
190                return new Locale(terms[0], "");
191
192            case 2:
193                return new Locale(terms[0], terms[1]);
194
195            case 3:
196                return new Locale(terms[0], terms[1], terms[2]);
197
198            default:
199                throw new IllegalArgumentException();
200        }
201    }
202
203    public boolean setLocaleFromLocaleName(String localeName)
204    {
205        boolean supported = isSupportedLocaleName(localeName);
206
207        if (supported)
208        {
209            Locale locale = findClosestSupportedLocale(toLocale(localeName));
210
211            persistentLocale.set(locale);
212
213            threadLocale.setLocale(locale);
214        }
215        else
216        {
217            Locale requestLocale = request.getLocale();
218
219            Locale supportedLocale = findClosestSupportedLocale(requestLocale);
220
221            threadLocale.setLocale(supportedLocale);
222        }
223
224        return supported;
225    }
226
227    public void setNonPersistentLocaleFromLocaleName(String localeName)
228    {
229        Locale requested = toLocale(localeName);
230
231        Locale supported = findClosestSupportedLocale(requested);
232
233        threadLocale.setLocale(supported);
234    }
235    
236    public void setNonPersistentLocaleFromRequest(Request request)
237    {
238        Locale locale = request.getLocale();
239        setNonPersistentLocaleFromLocaleName(locale.toString());
240    }
241
242    private Locale findClosestSupportedLocale(Locale desiredLocale)
243    {
244        String localeName = desiredLocale.toString();
245
246        while (true)
247        {
248            if (isSupportedLocaleName(localeName))
249                return toLocale(localeName);
250
251            localeName = stripTerm(localeName);
252
253            if (localeName.length() == 0)
254                break;
255        }
256
257        return defaultLocale;
258    }
259
260    static String stripTerm(String localeName)
261    {
262        int scorex = localeName.lastIndexOf('_');
263
264        return scorex < 0 ? "" : localeName.substring(0, scorex);
265    }
266
267    public List<Locale> getSupportedLocales()
268    {
269        return supportedLocales;
270    }
271
272    public boolean isSupportedLocaleName(String localeName)
273    {
274        return supportedLocaleNames.contains(localeName.toLowerCase());
275    }
276
277    public SelectModel getSupportedLocalesModel()
278    {
279        List<OptionModel> options = CollectionFactory.newList();
280
281        for (Locale l : supportedLocales)
282        {
283            options.add(new OptionModelImpl(l.getDisplayName(l), l));
284        }
285
286        return new SelectModelImpl(null, options);
287    }
288
289}