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}