/**
* eAdventure (formerly <e-Adventure> and <e-Game>) is a research project of the
* <e-UCM> research group.
*
* Copyright 2005-2010 <e-UCM> research group.
*
* You can access a list of all the contributors to eAdventure at:
* http://e-adventure.e-ucm.es/contributors
*
* <e-UCM> is a research group of the Department of Software Engineering
* and Artificial Intelligence at the Complutense University of Madrid
* (School of Computer Science).
*
* C Profesor Jose Garcia Santesmases sn,
* 28040 Madrid (Madrid), Spain.
*
* For more info please visit: <http://e-adventure.e-ucm.es> or
* <http://www.e-ucm.es>
*
* ****************************************************************************
*
* This file is part of eAdventure, version 2.0
*
* eAdventure is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* eAdventure is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with eAdventure. If not, see <http://www.gnu.org/licenses/>.
*/
package es.eucm.ead.editor.util.i18n;
import es.eucm.ead.tools.java.utils.clazz.ClassLoaderUtils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Common superclass for all message bundle classes. Provides convenience
* methods for manipulating messages. <p> The
* <code>#bind</code> methods perform string substitution and should be
* considered a convenience and <em>not</em> a full substitute replacement for
* <code>MessageFormat#format</code> method calls. </p> <p> Text appearing
* within curly braces in the given message, will be interpreted as a numeric
* index to the corresponding substitution object in the given array. Calling
* the
* <code>#bind</code> methods with text that does not map to an integer will
* result in an {@link java.lang.IllegalArgumentException} </p> <p> Text
* appearing within single quotes is treated as a literal. A single quote is
* escaped by a preceeding single quote. </p> <p> Clients who wish to use the
* full substitution power of the
* <code>MessageFormat</code> class should call that class directly and not use
* these
* <code>#bind</code> methods. </p> <p> Clients may subclass this type. </p>
*
* <p>This class is based on Eclipse {@link org.eclipse.osgi.util.NLS} class</p>
*/
public abstract class I18N {
private static Logger logger = LoggerFactory
.getLogger(I18N.class.getName());
private static final String referenceValueRegex = "[{]([a-z][a-z0-9_]+)[}]";
/**
* Creates a new I18N instance.
*/
protected I18N() {
super();
}
//
// API methods
//
/**
* Bind the given message's substitution locations with the given string
* values.
*
* @param message the message to be manipulated
* @param bindings An array of objects to be inserted into the message
* @return the manipulated String
*/
public static String bind(String message, Object... bindings) {
if (message == null) {
return "No message available."; //$NON-NLS-1$
}
if (bindings == null || bindings.length == 0) {
bindings = EMPTY_ARGS;
}
int length = message.length();
// estimate correct size of string buffer to avoid growth
int bufLen = length + (bindings.length * 5);
StringBuilder buffer = new StringBuilder(bufLen);
int i = 0;
while (i < length) {
char c = message.charAt(i);
switch (c) {
case '{':
int index = message.indexOf('}', i);
// if we don't have a matching closing brace then...
if (index == -1) {
buffer.append(c);
break;
}
i++;
if (i >= length) {
buffer.append(c);
break;
}
// look for a substitution
int number = -1;
String numberSubstring = message.substring(i, index);
try {
number = Integer.parseInt(numberSubstring);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("In message " + message
+ ", '" + numberSubstring + "' is not a number");
}
if (number >= bindings.length || number < 0) {
logger.warn("Missing argument for {} in {}", new Object[] {
numberSubstring, message });
buffer.append("<missing argument>"); //$NON-NLS-1$
i = index;
break;
}
buffer.append(bindings[number]);
i = index;
break;
case '\'':
// if a single quote is the last char on the line then skip it
int nextIndex = i + 1;
if (nextIndex >= length) {
buffer.append(c);
break;
}
char next = message.charAt(nextIndex);
// if the next char is another single quote then write out one
if (next == '\'') {
i++;
buffer.append(c);
break;
}
// otherwise we want to read until we get to the next single
// quote
index = message.indexOf('\'', nextIndex);
// if there are no more in the string, then skip it
if (index == -1) {
buffer.append(c);
break;
}
// otherwise write out the chars inside the quotes
buffer.append(message.substring(nextIndex, index));
i = index;
break;
default:
buffer.append(c);
}
i++;
}
return buffer.toString();
}
/**
* Initialize the given class with the values from the specified message
* bundle.
*
* @param bundleName fully qualified path of the class name
* @param clazz the class where the constants will exist
*/
public static void initializeMessages(final String bundleName,
final Class<?> clazz) {
if (System.getSecurityManager() == null) {
loadMessages(bundleName, clazz);
return;
}
AccessController.doPrivileged(new PrivilegedAction<Object>() {
@Override
public Object run() {
loadMessages(bundleName, clazz);
return null;
}
});
}
/**
* Initialize the given class with the values from the specified resources
* bundle.
*
* @param bundleName fully qualified path of the class name
* @param clazz the class where the constants will exist
* @param files
*/
public static void initializeResources(final String bundleName,
final Class<?> clazz, Set<String> files) {
loadResources(bundleName, clazz, files);
}
//
// Internal methods
//
/**
* Expected field's modifiers.
*/
private static final int MOD_EXPECTED = Modifier.PUBLIC | Modifier.STATIC;
/**
* Field's modifiers mask
*/
private static final int MOD_MASK = MOD_EXPECTED | Modifier.FINAL;
/**
* Load the given resource bundle using the specified class loader.
*
* @param baseName the base name of the resource bundle, a fully qualified
* class name
* @param clazz
* <code>Class</code> holding the messages.
*/
private static void loadMessages(final String baseName, final Class<?> clazz) {
loadMessages(baseName, clazz, Locale.getDefault());
}
/**
* Resolves all references within a string. A reference is something of
* the form {key}, which is replaced by props.get(key). Replacement is
* performed until no references remain, or until a set limit of
* replacement-iterations is reached.
*
* The final, substituted value is stored in its key for further reference.
*
* @param input
* @param props
* @return substituted value
*/
private static String resolveReferences(String key, String initialValue,
Properties props) {
Pattern p = Pattern.compile(referenceValueRegex);
String output = initialValue;
int maxIterations = 10;
boolean found = false;
for (int i = 0; i < maxIterations; i++) {
Matcher m = p.matcher(output);
found = false;
StringBuffer sb = new StringBuffer();
while (m.find()) {
found = true;
String value = props.getProperty(m.group(1));
if (value == null) {
value = m.group().replaceAll("[{}]", "?");
logger
.warn(
"Could not resolve value {} in key {}, iteration {}",
new Object[] { m.group(), key, "" + (i + 1) });
} else {
logger
.debug(
"Resolved value {} in key {} as {}, iteration {}",
new Object[] { m.group(), key, value,
"" + (i + 1) });
}
m.appendReplacement(sb, value);
}
if (!found) {
break;
}
// prepare for next iteration
m.appendTail(sb);
output = sb.toString();
}
if (found) {
// too many iterations: bail out
logger
.warn(
"Circular reference in key {}: reached {} replacement iterations",
new Object[] { key, "" + maxIterations });
}
props.setProperty(key, output);
return output;
}
/**
* Load the
* <code>baseName</code> messages bundle and initialize
* <code>clazz</code> fields.
*
* <p> This method initializes
* <code>clazz</code> public static String fields using the messages loaded
* from <code>baseName</code> bundle. </p>. References to other keys
* are replaced; but no guarantee is made regarding order-of-interpretation.
*
* @param baseName the base name of the resource bundle, a fully qualified
* class name
* @param locale the locale for which a resource bundle is desired
* @param clazz
* <code>Class</code> holding the messages.
*/
private static void loadMessages(final String baseName,
final Class<?> clazz, Locale locale) {
synchronized (clazz) {
Field[] fieldDecls = clazz.getDeclaredFields();
Map<String, Field> fields = new HashMap<String, Field>();
Set<String> fieldNames = new HashSet<String>();
for (int i = 0; i < fieldDecls.length; i++) {
String name = fieldDecls[i].getName();
fields.put(name, fieldDecls[i]);
fieldNames.add(name);
}
// Compute bundle file names using the default system Locale
boolean noBundleFound = true;
String[] fileNames = buildBundleFileNames(baseName, locale);
ClassLoader loader = ClassLoaderUtils.getClassLoader(I18N.class);
for (int i = 0; i < fileNames.length; i++) {
InputStream input = loader.getResourceAsStream(fileNames[i]);
logger.debug("Searching for file {} for bundle {}",
fileNames[i], baseName);
if (input == null) {
logger.debug("Bundle-file NOT FOUND in classpath:'{}'",
fileNames[i]);
continue;
}
logger.info("Processing file {} for bundle {}", fileNames[i],
baseName);
try {
Properties props = new Properties();
props.load(input);
for (Map.Entry<Object, Object> e : props.entrySet()) {
String key = (String) e.getKey();
String value = resolveReferences(key, e.getValue()
.toString(), props);
if (fieldNames.contains(key)) {
if (fields.containsKey(key)) {
logger.debug("setting key {} to '{}'",
new Object[] {
fields.get(key).getName(),
value });
assignField(fields.get(key), e.getValue());
fields.remove(key);
}
} else {
logger.warn("Bundle '{}' has an unused message {}",
new Object[] { baseName, key });
}
}
noBundleFound = false;
} catch (IOException e) {
logger.error("Error loading message bundle '{}'", baseName,
e);
} finally {
if (input != null) {
try {
input.close();
} catch (IOException e) {
logger.error(
"Error closing stream when loading for {}",
baseName, e);
}
}
}
}
if (noBundleFound) {
logger.error("No bundle (or fallback) found for {}", baseName);
}
//TODO: set the value to the default error resources
for (Map.Entry<String, Field> e : fields.entrySet()) {
Field field = e.getValue();
String value = "Bundle " + baseName + ": message "
+ field.getName() + " is missing";
assignField(field, value);
logger.warn(value);
}
}
}
private static void assignField(Field field, Object value) {
// Test if field has static and public modifiers
if ((field.getModifiers() & MOD_MASK) == MOD_EXPECTED) {
try {
/*
* Check to see if we are allowed to modify the field. If we
* aren't (for instance if the class is not public) then change
* the accessible attribute of the field before trying to set
* the value.
*/
boolean isAccessible = (field.getDeclaringClass()
.getModifiers() & Modifier.PUBLIC) != 0;
if (!isAccessible) {
/*
* Set the value into the field. We should never get an
* exception here because we know we have a public static
* non-final field. If we do get an exception, silently log
* it and continue. This means that the field will (most
* likely) be un-initialized and will fail later in the code
* and if so then we will see both the NPE and this error.
*/
boolean oldValue = field.isAccessible();
field.setAccessible(true);
field.set(null, value);
field.setAccessible(oldValue);
} else {
field.set(null, value);
}
} catch (Exception e) {
logger.error("Error setting field value for {}", field
.getName(), e);
}
}
}
private static final Object[] EMPTY_ARGS = new Object[0];
private static String[] I18N_SUFFIXES;
/**
* Build an array of property files to search.
*
* @param bundleName User provided properties file bundle name.
* @param locale User provided locale
*
* @return Returns an array of file names
*/
private static String[] buildBundleFileNames(String bundleName,
Locale locale) {
if (I18N_SUFFIXES == null) {
I18N_SUFFIXES = buildBundleFileNameSuffixes(locale);
}
bundleName = bundleName.replace('.', '/');
String[] variants = new String[I18N_SUFFIXES.length];
for (int i = 0; i < variants.length; i++) {
variants[i] = bundleName + I18N_SUFFIXES[i];
}
return variants;
}
/**
* Default messages bundle name file extension.
*/
private static final String EXTENSION = ".properties";
/**
* Calculate the bundle name suffixes for the system default locale.
*
* <p> Build the suffixes list for a particular
* <code>Locale</code> using a similar algorithm to the described in
* {@link java.util.ResourceBundle#getBundle(String, Locale, ClassLoader)}}
* </p>
*
* @param locale
* <code>Locale</code> use to build the suffixes list.
*
* @see java.util.ResourceBundle#getBundle(String, Locale, ClassLoader)
* @see java.util.ResourceBundle
*
* @return Return the list of bundle name suffixes
*/
private static String[] buildBundleFileNameSuffixes(Locale locale) {
ArrayList<String> result = new ArrayList<String>(4);
String localeString = locale.toString();
int lastSeparator;
// 1. Build the list of suffixes using the provided locale
do {
result.add('_' + localeString + EXTENSION);
lastSeparator = localeString.lastIndexOf('_');
if (lastSeparator != -1) {
localeString = localeString.substring(0, lastSeparator);
}
} while (lastSeparator != -1);
// 2. Build the list of suffixes using the default locale if needed
if (!locale.equals(Locale.getDefault())) {
do {
result.add('_' + localeString + EXTENSION);
lastSeparator = localeString.lastIndexOf('_');
if (lastSeparator != -1) {
localeString = localeString.substring(0, lastSeparator);
}
} while (lastSeparator != -1);
}
// 3. Add the default extension
result.add(EXTENSION);
return result.toArray(new String[0]);
}
/**
* Load the resources for the resources class with the default locale
*
* @param baseName The base name of the class
* @param clazz The class
* @param files
*/
private static void loadResources(final String baseName,
final Class<?> clazz, Set<String> files) {
loadResources(baseName, clazz, files, Locale.getDefault());
}
/**
* Load the resources for the resources class with the given locale
*
* @param baseName the base name of the class
* @param clazz the class
* @param files
* @param locale the locale
*/
private static void loadResources(final String baseName,
final Class<?> clazz, Set<String> files, Locale locale) {
synchronized (clazz) {
Field[] fieldDecls = clazz.getDeclaredFields();
Map<String, Field> fields = new HashMap<String, Field>();
List<String> fieldNames = new ArrayList<String>();
for (int i = 0; i < fieldDecls.length; i++) {
String name = fieldDecls[i].getName();
fields.put(name, fieldDecls[i]);
fieldNames.add(name);
}
String[] temp = baseName.split("[\\.$]");
String baseFolder = temp[temp.length - 1].toLowerCase();
for (int i = 0; i < fieldNames.size(); i++) {
String fieldName = fieldNames.get(i);
for (String name : buildResourceFileNames(fieldName, locale)) {
if (files.contains(name)) {
String toAssign = (name
.matches("[a-z][a-z](_[A-Z][A-Z])?/.*")) ? baseFolder
+ "-" + name
: baseFolder + "/" + name;
assignField(fields.get(fieldName), toAssign);
fields.remove(fieldName);
String value = "Bundle '" + baseName + "' resource "
+ fieldName + " OK";
logger.debug(value);
break;
}
}
}
//TODO: set the value to the default error resources
for (Map.Entry<String, Field> e : fields.entrySet()) {
Field field = e.getValue();
String value = "Bundle " + baseName + ": message "
+ field.getName() + " is missing";
assignField(field, value.replaceAll("[ :.]+", "_"));
logger.warn(value);
}
}
}
/**
* Build all the possible names for the file corresponding to fieldName
*
* @param fieldName The name of the field
* @param locale The current locale
* @return The list of possible file names
*/
private static ArrayList<String> buildResourceFileNames(String fieldName,
Locale locale) {
ArrayList<String> result = new ArrayList<String>();
String localeString = locale.toString();
int lastSeparator;
// from hi_my_name_is_bob to hi/my/name/is.bob
int lastUnderscore = fieldName.lastIndexOf('_');
String fileName = fieldName.substring(0, lastUnderscore) + "."
+ fieldName.substring(lastUnderscore + 1);
fileName = fileName.replaceAll("__", "/");
// 1. Build the list of suffixes using the provided locale
do {
result.add(localeString + File.separator + fileName);
lastSeparator = localeString.lastIndexOf('_');
if (lastSeparator != -1) {
localeString = localeString.substring(0, lastSeparator);
}
} while (lastSeparator != -1);
// 2. Build the list of suffixes using the default locale if needed
if (!locale.equals(Locale.getDefault())) {
do {
result.add(localeString + File.separator + fileName);
lastSeparator = localeString.lastIndexOf('_');
if (lastSeparator != -1) {
localeString = localeString.substring(0, lastSeparator);
}
} while (lastSeparator != -1);
}
// 3. Add the default extension
result.add(fileName);
if (logger.isDebugEnabled()) {
logger.debug("Generated {} alternatives for {}:", new Object[] {
result.size(), fieldName });
for (String s : result) {
logger.debug("\t{}", s);
}
}
return result;
}
}