com.google.escapevelocity.ReferenceNode Maven / Gradle / Ivy
/*
* Copyright (C) 2018 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 static java.util.stream.Collectors.toList;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Primitives;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 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 [email protected] (Éamonn McManus)
*/
abstract class ReferenceNode extends ExpressionNode {
final boolean silent;
ReferenceNode(String resourceName, int lineNumber, boolean silent) {
super(resourceName, lineNumber);
this.silent = silent;
}
@Override boolean isSilent() {
return silent;
}
EvaluationException evaluationExceptionInThis(String message) {
return evaluationException("In " + this + ": " + message);
}
/**
* Evaluates the first part of a complex reference, for example {@code $foo} in {@code $foo.bar}.
* It must not be null, and it must not be a macro's {@code $bodyContent} or the result of a
* {@code #define}.
*/
Object evaluateLhs(ReferenceNode lhs, EvaluationContext context) {
Object lhsValue = lhs.evaluate(context);
if (lhsValue == null) {
throw evaluationExceptionInThis(lhs + " must not be null");
} else if (lhsValue instanceof Node) {
throw evaluationExceptionInThis(lhs + " comes from #define or is a macro's $bodyContent");
}
return lhsValue;
}
/**
* 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, boolean silent) {
super(resourceName, lineNumber, silent);
this.id = id;
}
@Override public String toString() {
return "$" + id;
}
@Override Object evaluate(EvaluationContext context, boolean undefinedIsFalse) {
if (context.varIsDefined(id)) {
return context.getVar(id);
} else if (undefinedIsFalse) {
return false;
} else {
throw evaluationException("Undefined reference " + this);
}
}
}
/**
* 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, boolean silent) {
super(lhs.resourceName, lhs.lineNumber, silent);
this.lhs = lhs;
this.id = id;
}
private static final String[] PREFIXES = {"get", "is"};
private static final boolean[] CHANGE_CASE = {false, true};
@Override public String toString() {
return lhs + "." + id;
}
@Override Object evaluate(EvaluationContext context, boolean undefinedIsFalse) {
// We don't propagate undefinedIsFalse because we don't allow $foo.bar if $foo is undefined,
// even inside an #if expression.
Object lhsValue = evaluateLhs(lhs, context);
// If this is a Map, then Velocity looks up the property in the map.
if (lhsValue instanceof Map, ?>) {
Map, ?> map = (Map, ?>) lhsValue;
return map.get(id);
}
// 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;
Optional maybeMethod =
context.publicMethodsWithName(lhsValue.getClass(), methodName).stream()
.filter(m -> m.getParameterTypes().length == 0)
.findFirst();
if (maybeMethod.isPresent()) {
Method method = maybeMethod.get();
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());
}
}
}
}
throw evaluationExceptionInThis(
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, boolean silent) {
super(lhs.resourceName, lhs.lineNumber, silent);
this.lhs = lhs;
this.index = index;
}
@Override public String toString() {
return lhs + "[" + index + "]";
}
@Override Object evaluate(EvaluationContext context, boolean undefinedIsFalse) {
// We don't propagate undefinedIsFalse because we don't allow $foo[0] if $foo is undefined,
// even inside an #if expression.
Object lhsValue = evaluateLhs(lhs, context);
if (lhsValue instanceof List>) {
Object indexValue = index.evaluate(context);
if (!(indexValue instanceof Integer)) {
throw evaluationExceptionInThis("list index is not an Integer: " + indexValue);
}
List> lhsList = (List>) lhsValue;
int i = (Integer) indexValue;
if (i < 0) {
int newI = lhsList.size() + i;
if (newI < 0) {
throw evaluationExceptionInThis(
"negative list index "
+ i
+ " counts from the end of the list, but the list size is only "
+ lhsList.size());
}
i = newI;
}
if (i >= lhsList.size()) {
throw evaluationExceptionInThis(
"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), silent);
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 args;
MethodReferenceNode(ReferenceNode lhs, String id, List args, boolean silent) {
super(lhs.resourceName, lhs.lineNumber, silent);
this.lhs = lhs;
this.id = id;
this.args = args;
}
@Override public String toString() {
return lhs + "." + id + "(" + Joiner.on(", ").join(args) + ")";
}
/**
* {@inheritDoc}
*
* 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}.
*
*
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.
*
*
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, boolean undefinedIsFalse) {
// We don't propagate undefinedIsFalse because we don't allow $foo.bar() if $foo is undefined,
// even inside an #if expression.
Object lhsValue = evaluateLhs(lhs, context);
try {
return evaluate(context, lhsValue, lhsValue.getClass());
} catch (EvaluationException e) {
// If this is a Class, try invoking a static method of the class it refers to.
// This is what Apache Velocity does. If the method exists as both an instance method of
// Class and a static method of the referenced class, then it is the instance method of
// Class that wins, again consistent with Velocity.
if (lhsValue instanceof Class>) {
return evaluate(context, null, (Class>) lhsValue);
}
throw e;
}
}
private Object evaluate(EvaluationContext context, Object lhsValue, Class> targetClass) {
List