1 /* 2 * Copyright (C) 2018 Google, Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.escapevelocity; 17 18 import static java.util.stream.Collectors.toList; 19 20 import com.google.common.base.Joiner; 21 import com.google.common.collect.ImmutableList; 22 import com.google.common.collect.ImmutableSet; 23 import com.google.common.collect.Iterables; 24 import com.google.common.primitives.Primitives; 25 import java.lang.reflect.InvocationTargetException; 26 import java.lang.reflect.Method; 27 import java.util.List; 28 import java.util.Map; 29 import java.util.Optional; 30 31 /** 32 * A node in the parse tree that is a reference. A reference is anything beginning with {@code $}, 33 * such as {@code $x} or {@code $x[$i].foo($j)}. 34 * 35 * @author emcmanus@google.com (Éamonn McManus) 36 */ 37 abstract class ReferenceNode extends ExpressionNode { ReferenceNode(String resourceName, int lineNumber)38 ReferenceNode(String resourceName, int lineNumber) { 39 super(resourceName, lineNumber); 40 } 41 42 /** 43 * A node in the parse tree that is a plain reference such as {@code $x}. This node may appear 44 * inside a more complex reference like {@code $x.foo}. 45 */ 46 static class PlainReferenceNode extends ReferenceNode { 47 final String id; 48 PlainReferenceNode(String resourceName, int lineNumber, String id)49 PlainReferenceNode(String resourceName, int lineNumber, String id) { 50 super(resourceName, lineNumber); 51 this.id = id; 52 } 53 evaluate(EvaluationContext context)54 @Override Object evaluate(EvaluationContext context) { 55 if (context.varIsDefined(id)) { 56 return context.getVar(id); 57 } else { 58 throw evaluationException("Undefined reference $" + id); 59 } 60 } 61 62 @Override isDefinedAndTrue(EvaluationContext context)63 boolean isDefinedAndTrue(EvaluationContext context) { 64 if (context.varIsDefined(id)) { 65 return isTrue(context); 66 } else { 67 return false; 68 } 69 } 70 } 71 72 /** 73 * A node in the parse tree that is a reference to a property of another reference, like 74 * {@code $x.foo} or {@code $x[$i].foo}. 75 */ 76 static class MemberReferenceNode extends ReferenceNode { 77 final ReferenceNode lhs; 78 final String id; 79 MemberReferenceNode(ReferenceNode lhs, String id)80 MemberReferenceNode(ReferenceNode lhs, String id) { 81 super(lhs.resourceName, lhs.lineNumber); 82 this.lhs = lhs; 83 this.id = id; 84 } 85 86 private static final String[] PREFIXES = {"get", "is"}; 87 private static final boolean[] CHANGE_CASE = {false, true}; 88 evaluate(EvaluationContext context)89 @Override Object evaluate(EvaluationContext context) { 90 Object lhsValue = lhs.evaluate(context); 91 if (lhsValue == null) { 92 throw evaluationException("Cannot get member " + id + " of null value"); 93 } 94 // If this is a Map, then Velocity looks up the property in the map. 95 if (lhsValue instanceof Map<?, ?>) { 96 Map<?, ?> map = (Map<?, ?>) lhsValue; 97 return map.get(id); 98 } 99 // Velocity specifies that, given a reference .foo, it will first look for getfoo() and then 100 // for getFoo(), and likewise given .Foo it will look for getFoo() and then getfoo(). 101 for (String prefix : PREFIXES) { 102 for (boolean changeCase : CHANGE_CASE) { 103 String baseId = changeCase ? changeInitialCase(id) : id; 104 String methodName = prefix + baseId; 105 Optional<Method> maybeMethod = 106 context.publicMethodsWithName(lhsValue.getClass(), methodName).stream() 107 .filter(m -> m.getParameterTypes().length == 0) 108 .findFirst(); 109 if (maybeMethod.isPresent()) { 110 Method method = maybeMethod.get(); 111 if (!prefix.equals("is") || method.getReturnType().equals(boolean.class)) { 112 // Don't consider methods that happen to be called isFoo() but don't return boolean. 113 return invokeMethod(method, lhsValue, ImmutableList.of()); 114 } 115 } 116 } 117 } 118 throw evaluationException( 119 "Member " + id + " does not correspond to a public getter of " + lhsValue 120 + ", a " + lhsValue.getClass().getName()); 121 } 122 changeInitialCase(String id)123 private static String changeInitialCase(String id) { 124 int initial = id.codePointAt(0); 125 String rest = id.substring(Character.charCount(initial)); 126 if (Character.isUpperCase(initial)) { 127 initial = Character.toLowerCase(initial); 128 } else if (Character.isLowerCase(initial)) { 129 initial = Character.toUpperCase(initial); 130 } 131 return new StringBuilder().appendCodePoint(initial).append(rest).toString(); 132 } 133 } 134 135 /** 136 * A node in the parse tree that is an indexing of a reference, like {@code $x[0]} or 137 * {@code $x.foo[$i]}. Indexing is array indexing or calling the {@code get} method of a list 138 * or a map. 139 */ 140 static class IndexReferenceNode extends ReferenceNode { 141 final ReferenceNode lhs; 142 final ExpressionNode index; 143 IndexReferenceNode(ReferenceNode lhs, ExpressionNode index)144 IndexReferenceNode(ReferenceNode lhs, ExpressionNode index) { 145 super(lhs.resourceName, lhs.lineNumber); 146 this.lhs = lhs; 147 this.index = index; 148 } 149 evaluate(EvaluationContext context)150 @Override Object evaluate(EvaluationContext context) { 151 Object lhsValue = lhs.evaluate(context); 152 if (lhsValue == null) { 153 throw evaluationException("Cannot index null value"); 154 } 155 if (lhsValue instanceof List<?>) { 156 Object indexValue = index.evaluate(context); 157 if (!(indexValue instanceof Integer)) { 158 throw evaluationException("List index is not an integer: " + indexValue); 159 } 160 List<?> lhsList = (List<?>) lhsValue; 161 int i = (Integer) indexValue; 162 if (i < 0 || i >= lhsList.size()) { 163 throw evaluationException( 164 "List index " + i + " is not valid for list of size " + lhsList.size()); 165 } 166 return lhsList.get(i); 167 } else if (lhsValue instanceof Map<?, ?>) { 168 Object indexValue = index.evaluate(context); 169 Map<?, ?> lhsMap = (Map<?, ?>) lhsValue; 170 return lhsMap.get(indexValue); 171 } else { 172 // In general, $x[$y] is equivalent to $x.get($y). We've covered the most common cases 173 // above, but for other cases like Multimap we resort to evaluating the equivalent form. 174 MethodReferenceNode node = new MethodReferenceNode(lhs, "get", ImmutableList.of(index)); 175 return node.evaluate(context); 176 } 177 } 178 } 179 180 /** 181 * A node in the parse tree representing a method reference, like {@code $list.size()}. 182 */ 183 static class MethodReferenceNode extends ReferenceNode { 184 final ReferenceNode lhs; 185 final String id; 186 final List<ExpressionNode> args; 187 MethodReferenceNode(ReferenceNode lhs, String id, List<ExpressionNode> args)188 MethodReferenceNode(ReferenceNode lhs, String id, List<ExpressionNode> args) { 189 super(lhs.resourceName, lhs.lineNumber); 190 this.lhs = lhs; 191 this.id = id; 192 this.args = args; 193 } 194 195 /** 196 * {@inheritDoc} 197 * 198 * <p>Evaluating a method expression such as {@code $x.foo($y)} involves looking at the actual 199 * types of {@code $x} and {@code $y}. The type of {@code $x} must have a public method 200 * {@code foo} with a parameter type that is compatible with {@code $y}. 201 * 202 * <p>Currently we don't allow there to be more than one matching method. That is a difference 203 * from Velocity, which blithely allows you to invoke {@link List#remove(int)} even though it 204 * can't really know that you didn't mean to invoke {@link List#remove(Object)} with an Object 205 * that just happens to be an Integer. 206 * 207 * <p>The method to be invoked must be visible in a public class or interface that is either the 208 * class of {@code $x} itself or one of its supertypes. Allowing supertypes is important because 209 * you may want to invoke a public method like {@link List#size()} on a list whose class is not 210 * public, such as the list returned by {@link java.util.Collections#singletonList}. 211 */ evaluate(EvaluationContext context)212 @Override Object evaluate(EvaluationContext context) { 213 Object lhsValue = lhs.evaluate(context); 214 if (lhsValue == null) { 215 throw evaluationException("Cannot invoke method " + id + " on null value"); 216 } 217 try { 218 return evaluate(context, lhsValue, lhsValue.getClass()); 219 } catch (EvaluationException e) { 220 // If this is a Class, try invoking a static method of the class it refers to. 221 // This is what Apache Velocity does. If the method exists as both an instance method of 222 // Class and a static method of the referenced class, then it is the instance method of 223 // Class that wins, again consistent with Velocity. 224 if (lhsValue instanceof Class<?>) { 225 return evaluate(context, null, (Class<?>) lhsValue); 226 } 227 throw e; 228 } 229 } 230 evaluate(EvaluationContext context, Object lhsValue, Class<?> targetClass)231 private Object evaluate(EvaluationContext context, Object lhsValue, Class<?> targetClass) { 232 List<Object> argValues = args.stream() 233 .map(arg -> arg.evaluate(context)) 234 .collect(toList()); 235 ImmutableSet<Method> publicMethodsWithName = context.publicMethodsWithName(targetClass, id); 236 if (publicMethodsWithName.isEmpty()) { 237 throw evaluationException("No method " + id + " in " + targetClass.getName()); 238 } 239 List<Method> compatibleMethods = publicMethodsWithName.stream() 240 .filter(method -> compatibleArgs(method.getParameterTypes(), argValues)) 241 .collect(toList()); 242 // TODO(emcmanus): support varargs, if it's useful 243 if (compatibleMethods.size() > 1) { 244 compatibleMethods = 245 compatibleMethods.stream().filter(method -> !method.isSynthetic()).collect(toList()); 246 } 247 switch (compatibleMethods.size()) { 248 case 0: 249 throw evaluationException( 250 "Parameters for method " + id + " have wrong types: " + argValues); 251 case 1: 252 return invokeMethod(Iterables.getOnlyElement(compatibleMethods), lhsValue, argValues); 253 default: 254 throw evaluationException( 255 "Ambiguous method invocation, could be one of:\n " 256 + Joiner.on("\n ").join(compatibleMethods)); 257 } 258 } 259 260 /** 261 * Determines if the given argument list is compatible with the given parameter types. This 262 * includes an {@code Integer} argument being compatible with a parameter of type {@code int} or 263 * {@code long}, for example. 264 */ compatibleArgs(Class<?>[] paramTypes, List<Object> argValues)265 static boolean compatibleArgs(Class<?>[] paramTypes, List<Object> argValues) { 266 if (paramTypes.length != argValues.size()) { 267 return false; 268 } 269 for (int i = 0; i < paramTypes.length; i++) { 270 Class<?> paramType = paramTypes[i]; 271 Object argValue = argValues.get(i); 272 if (paramType.isPrimitive()) { 273 return primitiveIsCompatible(paramType, argValue); 274 } else if (argValue != null && !paramType.isInstance(argValue)) { 275 return false; 276 } 277 } 278 return true; 279 } 280 primitiveIsCompatible(Class<?> primitive, Object value)281 private static boolean primitiveIsCompatible(Class<?> primitive, Object value) { 282 if (value == null || !Primitives.isWrapperType(value.getClass())) { 283 return false; 284 } 285 return primitiveTypeIsAssignmentCompatible(primitive, Primitives.unwrap(value.getClass())); 286 } 287 288 private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.of( 289 byte.class, short.class, int.class, long.class, float.class, double.class); 290 private static final int INDEX_OF_INT = NUMERICAL_PRIMITIVES.indexOf(int.class); 291 292 /** 293 * Returns true if {@code from} can be assigned to {@code to} according to 294 * <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2">Widening 295 * Primitive Conversion</a>. 296 */ primitiveTypeIsAssignmentCompatible(Class<?> to, Class<?> from)297 static boolean primitiveTypeIsAssignmentCompatible(Class<?> to, Class<?> from) { 298 // To restate the JLS rules, f can be assigned to t if: 299 // - they are the same; or 300 // - f is char and t is a numeric type at least as wide as int; or 301 // - f comes before t in the order byte, short, int, long, float, double. 302 if (to == from) { 303 return true; 304 } 305 int toI = NUMERICAL_PRIMITIVES.indexOf(to); 306 if (toI < 0) { 307 return false; 308 } 309 if (from == char.class) { 310 return toI >= INDEX_OF_INT; 311 } 312 int fromI = NUMERICAL_PRIMITIVES.indexOf(from); 313 if (fromI < 0) { 314 return false; 315 } 316 return toI >= fromI; 317 } 318 } 319 320 /** 321 * Invoke the given method on the given target with the given arguments. 322 */ invokeMethod(Method method, Object target, List<Object> argValues)323 Object invokeMethod(Method method, Object target, List<Object> argValues) { 324 try { 325 return method.invoke(target, argValues.toArray()); 326 } catch (InvocationTargetException e) { 327 throw evaluationException(e.getCause()); 328 } catch (Exception e) { 329 throw evaluationException(e); 330 } 331 } 332 } 333