001/*
002 * ModeShape (http://www.modeshape.org)
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 org.modeshape.common.i18n;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.lang.reflect.Field;
021import java.lang.reflect.Modifier;
022import java.net.URL;
023import java.util.Collections;
024import java.util.HashSet;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.Properties;
029import java.util.Set;
030import java.util.concurrent.ConcurrentHashMap;
031import java.util.concurrent.ConcurrentMap;
032import java.util.concurrent.CopyOnWriteArraySet;
033import org.modeshape.common.CommonI18n;
034import org.modeshape.common.SystemFailureException;
035import org.modeshape.common.annotation.ThreadSafe;
036import org.modeshape.common.logging.Logger;
037import org.modeshape.common.util.CheckArg;
038import org.modeshape.common.util.ClassUtil;
039import org.modeshape.common.util.StringUtil;
040
041/**
042 * An internalized string object, which manages the initialization of internationalization (i18n) files, substitution of values
043 * within i18n message placeholders, and dynamically reading properties from i18n property files.
044 */
045@ThreadSafe
046public final class I18n implements I18nResource {
047
048    /**
049     * The first level of this map indicates whether an i18n class has been localized to a particular locale. The second level
050     * contains any problems encountered during localization.
051     *
052     * Make sure this is always the first member in the class because it must be initialized *before* the Logger (see below).
053     * Otherwise it's possible to trigger a NPE because of nested initializers.
054     */
055    static final ConcurrentMap<Locale, Map<Class<?>, Set<String>>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP = new ConcurrentHashMap<Locale, Map<Class<?>, Set<String>>>();
056
057    private static final Logger LOGGER = Logger.getLogger(I18n.class);
058
059    /**
060     * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
061     * 
062     * @param i18nClass The internalization class for which localization problem locales should be returned.
063     * @return The locales for which localization problems were encountered while localizing the supplied internationalization
064     *         class; never <code>null</code>.
065     */
066    public static Set<Locale> getLocalizationProblemLocales( Class<?> i18nClass ) {
067        CheckArg.isNotNull(i18nClass, "i18nClass");
068        Set<Locale> locales = new HashSet<Locale>(LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size());
069        for (Entry<Locale, Map<Class<?>, Set<String>>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
070            for (Entry<Class<?>, Set<String>> classEntry : localeEntry.getValue().entrySet()) {
071                if (!classEntry.getValue().isEmpty()) {
072                    locales.add(localeEntry.getKey());
073                    break;
074                }
075            }
076        }
077        return locales;
078    }
079
080    /**
081     * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
082     * 
083     * @param i18nClass The internalization class for which localization problems should be returned.
084     * @return The localization problems encountered while localizing the supplied internationalization class to the default
085     *         locale; never <code>null</code>.
086     */
087    public static Set<String> getLocalizationProblems( Class<?> i18nClass ) {
088        return getLocalizationProblems(i18nClass, Locale.getDefault());
089    }
090
091    /**
092     * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
093     * 
094     * @param i18nClass The internalization class for which localization problems should be returned.
095     * @param locale The locale for which localization problems should be returned. If <code>null</code>, the default locale will
096     *        be used.
097     * @return The localization problems encountered while localizing the supplied internationalization class to the supplied
098     *         locale; never <code>null</code>.
099     */
100    public static Set<String> getLocalizationProblems( Class<?> i18nClass,
101                                                       Locale locale ) {
102        CheckArg.isNotNull(i18nClass, "i18nClass");
103        Map<Class<?>, Set<String>> classToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get(locale == null ? Locale.getDefault() : locale);
104        if (classToProblemsMap == null) {
105            return Collections.emptySet();
106        }
107        Set<String> problems = classToProblemsMap.get(i18nClass);
108        if (problems == null) {
109            return Collections.emptySet();
110        }
111        return problems;
112    }
113
114    /**
115     * Initializes the internationalization fields declared on the supplied class. Internationalization fields must be public,
116     * static, not final, and of type <code>I18n</code>. The supplied class must not be an interface (of course), but has no
117     * restrictions as to what class it may extend or what interfaces it must implement.
118     * 
119     * @param i18nClass A class declaring one or more public, static, non-final fields of type <code>I18n</code>.
120     */
121    public static void initialize( Class<?> i18nClass ) {
122        validateI18nClass(i18nClass);
123
124        synchronized (i18nClass) {
125            // Find all public static non-final String fields in the supplied class and instantiate an I18n object for each.
126            try {
127                for (Field fld : i18nClass.getDeclaredFields()) {
128                    // Ensure field is of type I18n
129                    if (fld.getType() == I18n.class) {
130                        initializeI18nField(fld);
131                    }
132                }
133                cleanupPreviousProblems(i18nClass);
134            } catch (IllegalAccessException err) {
135                // If this happens, it will happen with the first field visited in the above loop
136                throw new IllegalArgumentException(CommonI18n.i18nClassNotPublic.text(i18nClass));
137            }
138        }
139    }
140
141    private static void cleanupPreviousProblems( Class<?> i18nClass ) {
142        // Remove all entries for the supplied i18n class to indicate it has not been localized.
143        for (Entry<Locale, Map<Class<?>, Set<String>>> entry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
144            entry.getValue().remove(i18nClass);
145        }
146    }
147
148    private static void initializeI18nField( Field fld ) throws IllegalAccessException {
149        // Ensure field is public
150        if ((fld.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) {
151            throw new SystemFailureException(CommonI18n.i18nFieldNotPublic.text(fld.getName(), fld.getDeclaringClass()));
152        }
153
154        // Ensure field is static
155        if ((fld.getModifiers() & Modifier.STATIC) != Modifier.STATIC) {
156            throw new SystemFailureException(CommonI18n.i18nFieldNotStatic.text(fld.getName(), fld.getDeclaringClass()));
157        }
158
159        // Ensure field is not final
160        if ((fld.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
161            throw new SystemFailureException(CommonI18n.i18nFieldFinal.text(fld.getName(), fld.getDeclaringClass()));
162        }
163
164        // Ensure we can access field even if it's in a private class
165        ClassUtil.makeAccessible(fld);
166
167        // Initialize field. Do this every time the class is initialized (or re-initialized)
168        fld.set(null, new I18n(fld.getName(), fld.getDeclaringClass()));
169    }
170
171    private static void validateI18nClass( Class<?> i18nClass ) {
172        CheckArg.isNotNull(i18nClass, "i18nClass");
173        if (i18nClass.isInterface()) {
174            throw new IllegalArgumentException(CommonI18n.i18nClassInterface.text(i18nClass.getName()));
175        }
176    }
177
178    /**
179     * Synchronized on the supplied internalization class.
180     * 
181     * @param i18nClass The internalization class being localized
182     * @param locale The locale to which the supplied internationalization class should be localized.
183     */
184    private static void localize( final Class<?> i18nClass,
185                                  final Locale locale ) {
186        assert i18nClass != null;
187        assert locale != null;
188        // Create a class-to-problem map for this locale if one doesn't exist, else get the existing one.
189        Map<Class<?>, Set<String>> classToProblemsMap = new ConcurrentHashMap<Class<?>, Set<String>>();
190        Map<Class<?>, Set<String>> existingClassToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.putIfAbsent(locale,
191                                                                                                            classToProblemsMap);
192        if (existingClassToProblemsMap != null) {
193            classToProblemsMap = existingClassToProblemsMap;
194        }
195        // Check if already localized outside of synchronization block for 99% use-case
196        if (classToProblemsMap.get(i18nClass) != null) {
197            return;
198        }
199        synchronized (i18nClass) {
200            // Return if the supplied i18n class has already been localized to the supplied locale, despite the check outside of
201            // the synchronization block (1% use-case), else create a class-to-problems map for the class.
202            Set<String> problems = classToProblemsMap.get(i18nClass);
203            if (problems == null) {
204                problems = new CopyOnWriteArraySet<String>();
205                classToProblemsMap.put(i18nClass, problems);
206            } else {
207                return;
208            }
209            // Get the URL to the localization properties file ...
210            final String localizationBaseName = i18nClass.getName();
211            URL bundleUrl = ClasspathLocalizationRepository.getLocalizationBundle(i18nClass.getClassLoader(), localizationBaseName, locale);
212            if (bundleUrl == null && i18nClass == CommonI18n.class) {
213                throw new SystemFailureException("CommonI18n.properties file not found in classpath !");
214            }
215            if (bundleUrl == null) {
216                LOGGER.warn(CommonI18n.i18nBundleNotFoundInClasspath,
217                            ClasspathLocalizationRepository.getPathsToSearchForBundle(localizationBaseName, locale));
218                // Nothing was found, so try the default locale
219                Locale defaultLocale = Locale.getDefault();
220                if (!defaultLocale.equals(locale)) {
221                    bundleUrl = ClasspathLocalizationRepository.getLocalizationBundle(i18nClass.getClassLoader(), localizationBaseName, defaultLocale);
222                }
223                // Return if no applicable localization file could be found
224                if (bundleUrl == null) {
225                    LOGGER.error(CommonI18n.i18nBundleNotFoundInClasspath,
226                                 ClasspathLocalizationRepository.getPathsToSearchForBundle(localizationBaseName, defaultLocale));
227                    LOGGER.error(CommonI18n.i18nLocalizationFileNotFound, localizationBaseName);
228                    problems.add(CommonI18n.i18nLocalizationFileNotFound.text(localizationBaseName));
229                    return;
230                }
231            }
232            // Initialize i18n map
233            Properties props = prepareBundleLoading(i18nClass, locale, bundleUrl, problems);
234
235            try {
236                InputStream propStream = bundleUrl.openStream();
237                try {
238                    props.load(propStream);
239                    // Check for uninitialized fields
240                    for (Field fld : i18nClass.getDeclaredFields()) {
241                        if (fld.getType() == I18n.class) {
242                            try {
243                                I18n i18n = (I18n)fld.get(null);
244                                if (i18n.localeToTextMap.get(locale) == null) {
245                                    i18n.localeToProblemMap.put(locale,
246                                                                CommonI18n.i18nPropertyMissing.text(fld.getName(), bundleUrl));
247                                }
248                            } catch (IllegalAccessException notPossible) {
249                                // Would have already occurred in initialize method, but allowing for the impossible...
250                                problems.add(notPossible.getMessage());
251                            }
252                        }
253                    }
254                } finally {
255                    propStream.close();
256                }
257            } catch (IOException err) {
258                problems.add(err.getMessage());
259            }
260        }
261    }
262
263    private static Properties prepareBundleLoading( final Class<?> i18nClass,
264                                                    final Locale locale,
265                                                    final URL bundleUrl,
266                                                    final Set<String> problems ) {
267        return new Properties() {
268            private static final long serialVersionUID = 3920620306881072843L;
269
270            @Override
271            public synchronized Object put( Object key,
272                                            Object value ) {
273                String id = (String)key;
274                String text = (String)value;
275
276                try {
277                    Field fld = i18nClass.getDeclaredField(id);
278                    if (fld.getType() != I18n.class) {
279                        // Invalid field type
280                        problems.add(CommonI18n.i18nFieldInvalidType.text(id, bundleUrl, getClass().getName()));
281                    } else {
282                        I18n i18n = (I18n)fld.get(null);
283                        if (i18n.localeToTextMap.putIfAbsent(locale, text) != null) {
284                            // Duplicate id encountered
285                            String prevProblem = i18n.localeToProblemMap.putIfAbsent(locale,
286                                                                                     CommonI18n.i18nPropertyDuplicate.text(id,
287                                                                                                                           bundleUrl));
288                            assert prevProblem == null;
289                        }
290                    }
291                } catch (NoSuchFieldException err) {
292                    // No corresponding field exists
293                    problems.add(CommonI18n.i18nPropertyUnused.text(id, bundleUrl));
294                } catch (IllegalAccessException notPossible) {
295                    // Would have already occurred in initialize method, but allowing for the impossible...
296                    problems.add(notPossible.getMessage());
297                }
298
299                return null;
300            }
301        };
302    }
303
304    private final String id;
305    private final Class<?> i18nClass;
306    final ConcurrentHashMap<Locale, String> localeToTextMap = new ConcurrentHashMap<Locale, String>();
307    final ConcurrentHashMap<Locale, String> localeToProblemMap = new ConcurrentHashMap<Locale, String>();
308
309    private I18n( String id,
310                  Class<?> i18nClass ) {
311        this.id = id;
312        this.i18nClass = i18nClass;
313    }
314
315    /**
316     * @return This internationalization object's ID, which will match both the name of the relevant static field in the
317     *         internationalization class and the relevant property name in the associated localization files.
318     */
319    public String id() {
320        return id;
321    }
322
323    /**
324     * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the default
325     *         locale.
326     */
327    public boolean hasProblem() {
328        return (problem() != null);
329    }
330
331    /**
332     * @param locale The locale for which to check whether a problem was encountered.
333     * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the supplied
334     *         locale.
335     */
336    public boolean hasProblem( Locale locale ) {
337        return (problem(locale) != null);
338    }
339
340    /**
341     * @return The problem encountered while localizing this internationalization object to the default locale, or
342     *         <code>null</code> if none was encountered.
343     */
344    public String problem() {
345        return problem(null);
346    }
347
348    /**
349     * @param locale The locale for which to return the problem.
350     * @return The problem encountered while localizing this internationalization object to the supplied locale, or
351     *         <code>null</code> if none was encountered.
352     */
353    public String problem( Locale locale ) {
354        if (locale == null) {
355            locale = Locale.getDefault();
356        }
357        localize(i18nClass, locale);
358        // Check for field/property error
359        String problem = localeToProblemMap.get(locale);
360        if (problem != null) {
361            return problem;
362        }
363        // Check if text exists
364        if (localeToTextMap.get(locale) != null) {
365            // If so, no problem exists
366            return null;
367        }
368        // If we get here, which will be at most once, there was at least one global localization error, so just return a message
369        // indicating to look them up.
370        problem = CommonI18n.i18nLocalizationProblems.text(i18nClass, locale);
371        localeToProblemMap.put(locale, problem);
372        return problem;
373    }
374
375    private String rawText( Locale locale ) {
376        assert locale != null;
377        localize(i18nClass, locale);
378        // Check if text exists
379        String text = localeToTextMap.get(locale);
380        if (text != null) {
381            return text;
382        }
383        // If not, there was a problem, so throw it within an exception so upstream callers can tell the difference between normal
384        // text and problem text.
385        throw new SystemFailureException(problem(locale));
386    }
387
388    /**
389     * Get the localized text for the {@link Locale#getDefault() current (default) locale}, replacing the parameters in the text
390     * with those supplied.
391     * 
392     * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
393     * @return the localized text
394     */
395    @Override
396    public String text( Object... arguments ) {
397        return text(null, arguments);
398    }
399
400    /**
401     * Get the localized text for the supplied locale, replacing the parameters in the text with those supplied.
402     * 
403     * @param locale the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used
404     * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
405     * @return the localized text
406     */
407    @Override
408    public String text( Locale locale,
409                        Object... arguments ) {
410        try {
411            String rawText = rawText(locale == null ? Locale.getDefault() : locale);
412            return StringUtil.createString(rawText, arguments);
413        } catch (IllegalArgumentException err) {
414            throw new IllegalArgumentException(CommonI18n.i18nRequiredToSuppliedParameterMismatch.text(id,
415                                                                                                       i18nClass,
416                                                                                                       err.getMessage()));
417        } catch (SystemFailureException err) {
418            return '<' + err.getMessage() + '>';
419        }
420    }
421
422    @Override
423    public String toString() {
424        try {
425            return rawText(Locale.getDefault());
426        } catch (SystemFailureException err) {
427            return '<' + err.getMessage() + '>';
428        }
429    }
430}