package com.google.sitebricks;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.inject.Binder;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.name.Named;
import com.google.sitebricks.compiler.ExpressionCompileException;
import com.google.sitebricks.compiler.MvelEvaluatorCompiler;
import com.google.sitebricks.compiler.Parsing;
import com.google.sitebricks.compiler.Token;
import com.google.sitebricks.i18n.Message;
import com.google.sitebricks.rendering.Strings;
/**
* A Utility that binds a localizable interface to its instance parameters.
*/
public class Localizer {
private final Binder binder;
private final Set<Localization> localizations;
// These are the processed, individual message sets by locale.
private final Map<String, Map<String, MessageDescriptor>> localizedValues = Maps.newHashMap();
// A map to track if we have bound the proxy for a given i18n interface yet.
private Set<Class<?>> i18nedSoFar = Sets.newHashSet();
/**
* A value object that represents the localization of an i18n interface to a locale
* and corresponding set of messages.
*/
public static class Localization {
// TODO(dhanji): Convert class reference to weak?
private final Class<?> clazz;
private final Locale locale;
private final Map<String, String> messageBundle;
public Localization(Class<?> clazz, Locale locale, Map<String, String> messageBundle) {
this.clazz = clazz;
this.locale = locale;
this.messageBundle = messageBundle;
}
public Class<?> getClazz() {
return this.clazz;
}
public Locale getLocale() {
return this.locale;
}
public Map<String, String> getMessageBundle() {
return this.messageBundle;
}
}
static final Localization DEFAULT = new Localization(null, null, null);
private Localizer(Binder binder, Set<Localization> localizations) {
this.binder = binder;
this.localizations = localizations;
}
public static void localizeAll(Binder binder, Set<Localization> localizations) {
new Localizer(binder, localizations).localize();
}
private void localize() {
for (Localization localization : localizations) {
// First scan and ensure that all methods on the interface contain i18n params.
bindMessages(localization);
}
// We're done with this so we don't need the set anymore.
i18nedSoFar = null;
}
private void bindMessages(Localization localization) {
Class<?> iface = localization.clazz;
Map<String, MessageDescriptor> messages = Maps.newHashMap();
for (Method method : iface.getMethods()) {
Message message = method.getAnnotation(Message.class);
check(null != message,
"Found an i18n interface method missing @Message annotation: ", iface, method);
if (null != message) {
check(!Strings.empty(message.message()),
"Empty @Message annotation is not allowed ", iface, method);
}
String template = localization.messageBundle.get(method.getName());
check(null != template,
"Provided resource bundle does not contain a localization for message: ", iface, method);
check(String.class.equals(method.getReturnType()),
"All i18n interface methods MUST return String: ", iface, method);
int argumentCount = method.getParameterTypes().length;
Map<String, Type> arguments = Maps.newLinkedHashMap();
for (int i = 0; i < argumentCount; i++) {
Annotation[] annotations = method.getParameterAnnotations()[i];
check(annotations.length == 1,
"Only @Named annotations are allowed on i18n method arguments: ", iface, method);
if (annotations.length == 0) {
continue;
}
check(Named.class.isInstance(annotations[0]),
"Named annotation is missing from i18n interface method argument: ", iface, method);
// Bind each argument to a template parameter a la Dynamic Finders.
arguments.put(((Named) annotations[0]).value(), method.getParameterTypes()[i]);
}
// No point in throwing an NPE ourselves, but we want to keep processing errors so continue
if (null == template || null == message) {
continue;
}
// Compile arg names against message template to ensure it works.
List<Token> tokens = null;
try {
MvelEvaluatorCompiler compiler = new MvelEvaluatorCompiler(arguments);
// Compile both the default message as well as the provided localized one.
Parsing.tokenize(message.message(), compiler);
tokens = Parsing.tokenize(template, compiler);
} catch (ExpressionCompileException e) {
check(false, "Compile error in i18n message template: \n " + e.getError().getError() +
" in expression " + e.getError().getExpression() +"\n\n ...in: ", iface, method);
}
// OK now actually go through and build a map between method names and values.
messages.put(method.getName(), new MessageDescriptor(tokens, arguments));
}
bindMessageProvider(iface, localization, messages);
}
@SuppressWarnings("unchecked") // We have a guarantee that Proxy will only return subtypes.
private void bindMessageProvider(final Class<?> iface,
Localization localization,
Map<String, MessageDescriptor> messages) {
// Add to the value map.
localizedValues.put(createLocaleInterfaceKey(iface, localization.locale), messages);
// Only need to bind the proxy once, for all locales.
if (!i18nedSoFar.contains(iface)) {
i18nedSoFar.add(iface);
binder.bind((Class)iface).toProvider(new Provider() {
// Wonderful Guice hack to get around not using assisted inject.
@Inject
private final Provider<HttpServletRequest> requestProvider = null;
// This is our delegate field that proxies the interface.
private final Object instance = Proxy.newProxyInstance(
Thread.currentThread().getContextClassLoader(),
new Class[] { iface }, new InvocationHandler() {
/**
* Returns the localized message bundle value, keyed by the method name invoked.
*/
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Locale locale = requestProvider.get().getLocale();
Map<String, MessageDescriptor> messages = getMessagesWithFallback(locale);
// Use default if we don't support the given locale.
if (null == messages) {
messages = getMessagesWithFallback(Locale.getDefault());
}
MessageDescriptor descriptor = messages.get(method.getName());
if (descriptor == null) {
throw new IllegalStateException("Could not find message '"
+ method.getName() + "' in " + messages);
}
return descriptor.render(args);
}
private Map<String, MessageDescriptor> getMessagesWithFallback(Locale locale) {
String localeInterfaceKey = createLocaleInterfaceKey(iface, locale);
Map<String, MessageDescriptor> result = localizedValues.get(localeInterfaceKey);
if (result == null) {
result = localizedValues.get(new Locale(locale.getLanguage()));
}
return result;
}
});
// return our proxy here.
public Object get() {
return instance;
}
});
}
}
private String createLocaleInterfaceKey(final Class<?> iface, Locale locale) {
return locale.toString() + ":" + iface.getName();
}
private static class MessageDescriptor {
private final List<Token> tokens;
private final Map<String, Type> argumentTypes;
private MessageDescriptor(List<Token> tokens, Map<String, Type> argumentTypes) {
this.tokens = tokens;
this.argumentTypes = argumentTypes;
}
public String render(Object[] args) {
Map<String, Object> arguments = Maps.newHashMap();
int i = 0;
for (String name : argumentTypes.keySet()) {
arguments.put(name, args[i]);
i++;
}
return Parsing.render(tokens, arguments);
}
}
private void check(boolean condition, String error, Class<?> key, Method method) {
if (!condition) {
binder.addError(error + "\n at " + key.getName() + "." + method.getName() + "()\n");
}
}
/**
* Returns a localization value object describing the defaults specified in the @Message
* annotations of the methods on the given i18n interface. The locale used is the system
* default.
*/
public static Localization defaultLocalizationFor(Class<?> iface) {
Map<String, String> defaultMessages = Maps.newHashMap();
for (Method method : iface.getMethods()) {
Message msg = method.getAnnotation(Message.class);
if (null != msg) {
defaultMessages.put(method.getName(), msg.message());
}
}
return new Localization(iface, Locale.getDefault(), defaultMessages);
}
}