diff options
Diffstat (limited to 'src/main/java/com/google/escapevelocity/ReferenceNode.java')
-rw-r--r-- | src/main/java/com/google/escapevelocity/ReferenceNode.java | 436 |
1 files changed, 436 insertions, 0 deletions
diff --git a/src/main/java/com/google/escapevelocity/ReferenceNode.java b/src/main/java/com/google/escapevelocity/ReferenceNode.java new file mode 100644 index 0000000..865d02a --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A node in the parse tree that is a reference. A reference is anything beginning with {@code $}, + * such as {@code $x} or {@code $x[$i].foo($j)}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class ReferenceNode extends ExpressionNode { + ReferenceNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * A node in the parse tree that is a plain reference such as {@code $x}. This node may appear + * inside a more complex reference like {@code $x.foo}. + */ + static class PlainReferenceNode extends ReferenceNode { + final String id; + + PlainReferenceNode(String resourceName, int lineNumber, String id) { + super(resourceName, lineNumber); + this.id = id; + } + + @Override Object evaluate(EvaluationContext context) { + if (context.varIsDefined(id)) { + return context.getVar(id); + } else { + throw evaluationException("Undefined reference $" + id); + } + } + + @Override + boolean isDefinedAndTrue(EvaluationContext context) { + if (context.varIsDefined(id)) { + return isTrue(context); + } else { + return false; + } + } + } + + /** + * A node in the parse tree that is a reference to a property of another reference, like + * {@code $x.foo} or {@code $x[$i].foo}. + */ + static class MemberReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final String id; + + MemberReferenceNode(ReferenceNode lhs, String id) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.id = id; + } + + private static final String[] PREFIXES = {"get", "is"}; + private static final boolean[] CHANGE_CASE = {false, true}; + + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot get member " + id + " of null value"); + } + // Velocity specifies that, given a reference .foo, it will first look for getfoo() and then + // for getFoo(), and likewise given .Foo it will look for getFoo() and then getfoo(). + for (String prefix : PREFIXES) { + for (boolean changeCase : CHANGE_CASE) { + String baseId = changeCase ? changeInitialCase(id) : id; + String methodName = prefix + baseId; + Method method; + try { + method = lhsValue.getClass().getMethod(methodName); + if (!prefix.equals("is") || method.getReturnType().equals(boolean.class)) { + // Don't consider methods that happen to be called isFoo() but don't return boolean. + return invokeMethod(method, lhsValue, ImmutableList.of()); + } + } catch (NoSuchMethodException e) { + // Continue with next possibility + } + } + } + throw evaluationException( + "Member " + id + " does not correspond to a public getter of " + lhsValue + + ", a " + lhsValue.getClass().getName()); + } + + private static String changeInitialCase(String id) { + int initial = id.codePointAt(0); + String rest = id.substring(Character.charCount(initial)); + if (Character.isUpperCase(initial)) { + initial = Character.toLowerCase(initial); + } else if (Character.isLowerCase(initial)) { + initial = Character.toUpperCase(initial); + } + return new StringBuilder().appendCodePoint(initial).append(rest).toString(); + } + } + + /** + * A node in the parse tree that is an indexing of a reference, like {@code $x[0]} or + * {@code $x.foo[$i]}. Indexing is array indexing or calling the {@code get} method of a list + * or a map. + */ + static class IndexReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final ExpressionNode index; + + IndexReferenceNode(ReferenceNode lhs, ExpressionNode index) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.index = index; + } + + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot index null value"); + } + if (lhsValue instanceof List<?>) { + Object indexValue = index.evaluate(context); + if (!(indexValue instanceof Integer)) { + throw evaluationException("List index is not an integer: " + indexValue); + } + List<?> lhsList = (List<?>) lhsValue; + int i = (Integer) indexValue; + if (i < 0 || i >= lhsList.size()) { + throw evaluationException( + "List index " + i + " is not valid for list of size " + lhsList.size()); + } + return lhsList.get(i); + } else if (lhsValue instanceof Map<?, ?>) { + Object indexValue = index.evaluate(context); + Map<?, ?> lhsMap = (Map<?, ?>) lhsValue; + return lhsMap.get(indexValue); + } else { + // In general, $x[$y] is equivalent to $x.get($y). We've covered the most common cases + // above, but for other cases like Multimap we resort to evaluating the equivalent form. + MethodReferenceNode node = new MethodReferenceNode(lhs, "get", ImmutableList.of(index)); + return node.evaluate(context); + } + } + } + + /** + * A node in the parse tree representing a method reference, like {@code $list.size()}. + */ + static class MethodReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final String id; + final List<ExpressionNode> args; + + MethodReferenceNode(ReferenceNode lhs, String id, List<ExpressionNode> args) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.id = id; + this.args = args; + } + + /** + * {@inheritDoc} + * + * <p>Evaluating a method expression such as {@code $x.foo($y)} involves looking at the actual + * types of {@code $x} and {@code $y}. The type of {@code $x} must have a public method + * {@code foo} with a parameter type that is compatible with {@code $y}. + * + * <p>Currently we don't allow there to be more than one matching method. That is a difference + * from Velocity, which blithely allows you to invoke {@link List#remove(int)} even though it + * can't really know that you didn't mean to invoke {@link List#remove(Object)} with an Object + * that just happens to be an Integer. + * + * <p>The method to be invoked must be visible in a public class or interface that is either the + * class of {@code $x} itself or one of its supertypes. Allowing supertypes is important because + * you may want to invoke a public method like {@link List#size()} on a list whose class is not + * public, such as the list returned by {@link java.util.Collections#singletonList}. + */ + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot invoke method " + id + " on null value"); + } + List<Object> argValues = new ArrayList<>(); + for (ExpressionNode arg : args) { + argValues.add(arg.evaluate(context)); + } + List<Method> methodsWithName = new ArrayList<>(); + for (Method method : lhsValue.getClass().getMethods()) { + if (method.getName().equals(id) && !method.isSynthetic()) { + methodsWithName.add(method); + } + } + if (methodsWithName.isEmpty()) { + throw evaluationException("No method " + id + " in " + lhsValue.getClass().getName()); + } + List<Method> compatibleMethods = new ArrayList<>(); + for (Method method : methodsWithName) { + // TODO(emcmanus): support varargs, if it's useful + if (compatibleArgs(method.getParameterTypes(), argValues)) { + compatibleMethods.add(method); + } + } + switch (compatibleMethods.size()) { + case 0: + throw evaluationException( + "Parameters for method " + id + " have wrong types: " + argValues); + case 1: + return invokeMethod(compatibleMethods.get(0), lhsValue, argValues); + default: + StringBuilder error = new StringBuilder("Ambiguous method invocation, could be one of:"); + for (Method method : compatibleMethods) { + error.append("\n ").append(method); + } + throw evaluationException(error.toString()); + } + } + + /** + * Determines if the given argument list is compatible with the given parameter types. This + * includes an {@code Integer} argument being compatible with a parameter of type {@code int} or + * {@code long}, for example. + */ + static boolean compatibleArgs(Class<?>[] paramTypes, List<Object> argValues) { + if (paramTypes.length != argValues.size()) { + return false; + } + for (int i = 0; i < paramTypes.length; i++) { + Class<?> paramType = paramTypes[i]; + Object argValue = argValues.get(i); + if (paramType.isPrimitive()) { + return primitiveIsCompatible(paramType, argValue); + } else if (!paramType.isInstance(argValue)) { + return false; + } + } + return true; + } + + private static final Map<Class<?>, Class<?>> BOXED_TO_UNBOXED; + static { + Map<Class<?>, Class<?>> map = new HashMap<>(); + map.put(Byte.class, byte.class); + map.put(Short.class, short.class); + map.put(Integer.class, int.class); + map.put(Long.class, long.class); + map.put(Float.class, float.class); + map.put(Double.class, double.class); + map.put(Character.class, char.class); + map.put(Boolean.class, boolean.class); + BOXED_TO_UNBOXED = Collections.unmodifiableMap(map); + } + + private static boolean primitiveIsCompatible(Class<?> primitive, Object value) { + if (value == null) { + return false; + } + Class<?> unboxed = BOXED_TO_UNBOXED.get(value.getClass()); + if (unboxed == null) { + return false; + } + return primitiveTypeIsAssignmentCompatible(primitive, unboxed); + } + + private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.<Class<?>>of( + byte.class, short.class, int.class, long.class, float.class, double.class); + private static final int INDEX_OF_INT = NUMERICAL_PRIMITIVES.indexOf(int.class); + + /** + * Returns true if {@code from} can be assigned to {@code to} according to + * <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2">Widening + * Primitive Conversion</a>. + */ + static boolean primitiveTypeIsAssignmentCompatible(Class<?> to, Class<?> from) { + // To restate the JLS rules, f can be assigned to t if: + // - they are the same; or + // - f is char and t is a numeric type at least as wide as int; or + // - f comes before t in the order byte, short, int, long, float, double. + if (to == from) { + return true; + } + int toI = NUMERICAL_PRIMITIVES.indexOf(to); + if (toI < 0) { + return false; + } + if (from == char.class) { + return toI >= INDEX_OF_INT; + } + int fromI = NUMERICAL_PRIMITIVES.indexOf(from); + if (fromI < 0) { + return false; + } + return toI >= fromI; + } + } + + /** + * Invoke the given method on the given target with the given arguments. The method is expected + * to be public, but the class it is in might not be. In that case we will search up the + * hierarchy for an ancestor that is public and has the same method, and use that to invoke the + * method. Otherwise we would get an {@link IllegalAccessException}. More than one ancestor might + * define the method, but it doesn't matter which one we invoke since ultimately the code that + * will run will be the same. + */ + Object invokeMethod(Method method, Object target, List<Object> argValues) { + if (!classIsPublic(target.getClass())) { + method = visibleMethod(method, target.getClass()); + if (method == null) { + throw evaluationException( + "Method is not visible in class " + target.getClass().getName() + ": " + method); + } + } + try { + return method.invoke(target, argValues.toArray()); + } catch (InvocationTargetException e) { + throw evaluationException(e.getCause()); + } catch (Exception e) { + throw evaluationException(e); + } + } + + private static String packageNameOf(Class<?> c) { + String name = c.getName(); + int lastDot = name.lastIndexOf('.'); + if (lastDot > 0) { + return name.substring(0, lastDot); + } else { + return ""; + } + } + + private static final String THIS_PACKAGE = packageNameOf(Node.class) + "."; + + /** + * Returns a Method with the same name and parameter types as the given one, but that is in a + * public class or interface. This might be the given method, or it might be a method in a + * superclass or superinterface. + * + * @return a public method in a public class or interface, or null if none was found. + */ + static Method visibleMethod(Method method, Class<?> in) { + if (in == null) { + return null; + } + Method methodInClass; + try { + methodInClass = in.getMethod(method.getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + return null; + } + if (classIsPublic(in) || in.getName().startsWith(THIS_PACKAGE)) { + // The second disjunct is a hack to allow us to use the methods of $foreach without having + // to make the ForEachVar class public. We can invoke those methods from here since they + // are in the same package. + return methodInClass; + } + Method methodSuper = visibleMethod(method, in.getSuperclass()); + if (methodSuper != null) { + return methodSuper; + } + for (Class<?> intf : in.getInterfaces()) { + Method methodIntf = visibleMethod(method, intf); + if (methodIntf != null) { + return methodIntf; + } + } + return null; + } + + /** + * Returns whether the given class is public as seen from this class. Prior to Java 9, a class + * was either public or not public. But with the introduction of modules in Java 9, a class can + * be marked public and yet not be visible, if it is not exported from the module it appears in. + * So, on Java 9, we perform an additional check on class {@code c}, which is effectively + * {@code c.getModule().isExported(c.getPackageName())}. We use reflection so that the code can + * compile on earlier Java versions. + */ + private static boolean classIsPublic(Class<?> c) { + if (!Modifier.isPublic(c.getModifiers())) { + return false; + } + if (CLASS_GET_MODULE_METHOD != null) { + return classIsExported(c); + } + return true; + } + + private static boolean classIsExported(Class<?> c) { + try { + String pkg = packageNameOf(c); + Object module = CLASS_GET_MODULE_METHOD.invoke(c); + return (Boolean) MODULE_IS_EXPORTED_METHOD.invoke(module, pkg); + } catch (Exception e) { + return false; + } + } + + private static final Method CLASS_GET_MODULE_METHOD; + private static final Method MODULE_IS_EXPORTED_METHOD; + + static { + Method classGetModuleMethod; + Method moduleIsExportedMethod; + try { + classGetModuleMethod = Class.class.getMethod("getModule"); + Class<?> moduleClass = classGetModuleMethod.getReturnType(); + moduleIsExportedMethod = moduleClass.getMethod("isExported", String.class); + } catch (Exception e) { + classGetModuleMethod = null; + moduleIsExportedMethod = null; + } + CLASS_GET_MODULE_METHOD = classGetModuleMethod; + MODULE_IS_EXPORTED_METHOD = moduleIsExportedMethod; + } +} |