diff options
Diffstat (limited to 'src')
17 files changed, 4173 insertions, 0 deletions
diff --git a/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java new file mode 100644 index 0000000..50fc9bc --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java @@ -0,0 +1,43 @@ +/* + * 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; + +/** + * A node in the parse tree representing a constant value. Evaluating the node yields the constant + * value. Instances of this class are used both in expressions, like the {@code 23} in + * {@code #set ($x = 23)}, and for literal text in templates. In the template... + * <pre>{@code + * abc#{if}($x == 5)def#{end}xyz + * }</pre> + * ...each of the strings {@code abc}, {@code def}, {@code xyz} is represented by an instance of + * this class that {@linkplain #evaluate evaluates} to that string, and the value {@code 5} is + * represented by an instance of this class that evaluates to the integer 5. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ConstantExpressionNode extends ExpressionNode { + private final Object value; + + ConstantExpressionNode(String resourceName, int lineNumber, Object value) { + super(resourceName, lineNumber); + this.value = value; + } + + @Override + Object evaluate(EvaluationContext context) { + return value; + } +} diff --git a/src/main/java/com/google/escapevelocity/DirectiveNode.java b/src/main/java/com/google/escapevelocity/DirectiveNode.java new file mode 100644 index 0000000..fd0cd22 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/DirectiveNode.java @@ -0,0 +1,226 @@ +/* + * 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 com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +/** + * A node in the parse tree that is a directive such as {@code #set ($x = $y)} + * or {@code #if ($x) y #end}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class DirectiveNode extends Node { + DirectiveNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * A node in the parse tree representing a {@code #set} construct. Evaluating + * {@code #set ($x = 23)} will set {@code $x} to the value 23. It does not in itself produce + * any text in the output. + * + * <p>Velocity supports setting values within arrays or collections, with for example + * {@code $set ($x[$i] = $y)}. That is not currently supported here. + */ + static class SetNode extends DirectiveNode { + private final String var; + private final Node expression; + + SetNode(String var, Node expression) { + super(expression.resourceName, expression.lineNumber); + this.var = var; + this.expression = expression; + } + + @Override + Object evaluate(EvaluationContext context) { + context.setVar(var, expression.evaluate(context)); + return ""; + } + } + + /** + * A node in the parse tree representing an {@code #if} construct. All instances of this class + * have a <i>true</i> subtree and a <i>false</i> subtree. For a plain {@code #if (cond) body + * #end}, the false subtree will be empty. For {@code #if (cond1) body1 #elseif (cond2) body2 + * #else body3 #end}, the false subtree will contain a nested {@code IfNode}, as if {@code #else + * #if} had been used instead of {@code #elseif}. + */ + static class IfNode extends DirectiveNode { + private final ExpressionNode condition; + private final Node truePart; + private final Node falsePart; + + IfNode( + String resourceName, + int lineNumber, + ExpressionNode condition, + Node trueNode, + Node falseNode) { + super(resourceName, lineNumber); + this.condition = condition; + this.truePart = trueNode; + this.falsePart = falseNode; + } + + @Override Object evaluate(EvaluationContext context) { + Node branch = condition.isDefinedAndTrue(context) ? truePart : falsePart; + return branch.evaluate(context); + } + } + + /** + * A node in the parse tree representing a {@code #foreach} construct. While evaluating + * {@code #foreach ($x in $things)}, {$code $x} will be set to each element of {@code $things} in + * turn. Once the loop completes, {@code $x} will go back to whatever value it had before, which + * might be undefined. During loop execution, the variable {@code $foreach} is also defined. + * Velocity defines a number of properties in this variable, but here we only support + * {@code $foreach.hasNext}. + */ + static class ForEachNode extends DirectiveNode { + private final String var; + private final ExpressionNode collection; + private final Node body; + + ForEachNode(String resourceName, int lineNumber, String var, ExpressionNode in, Node body) { + super(resourceName, lineNumber); + this.var = var; + this.collection = in; + this.body = body; + } + + @Override + Object evaluate(EvaluationContext context) { + Object collectionValue = collection.evaluate(context); + Iterable<?> iterable; + if (collectionValue instanceof Iterable<?>) { + iterable = (Iterable<?>) collectionValue; + } else if (collectionValue instanceof Object[]) { + iterable = Arrays.asList((Object[]) collectionValue); + } else if (collectionValue instanceof Map<?, ?>) { + iterable = ((Map<?, ?>) collectionValue).values(); + } else { + throw evaluationException("Not iterable: " + collectionValue); + } + Runnable undo = context.setVar(var, null); + StringBuilder sb = new StringBuilder(); + CountingIterator it = new CountingIterator(iterable.iterator()); + Runnable undoForEach = context.setVar("foreach", new ForEachVar(it)); + while (it.hasNext()) { + context.setVar(var, it.next()); + sb.append(body.evaluate(context)); + } + undoForEach.run(); + undo.run(); + return sb.toString(); + } + + private static class CountingIterator implements Iterator<Object> { + private final Iterator<?> iterator; + private int index = -1; + + CountingIterator(Iterator<?> iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Object next() { + Object next = iterator.next(); + index++; + return next; + } + + int index() { + return index; + } + } + + /** + * This class is the type of the variable {@code $foreach} that is defined within + * {@code #foreach} loops. Its {@link #getHasNext()} method means that we can write + * {@code #if ($foreach.hasNext)} and likewise for {@link #getIndex()}. + */ + private static class ForEachVar { + private final CountingIterator iterator; + + ForEachVar(CountingIterator iterator) { + this.iterator = iterator; + } + + public boolean getHasNext() { + return iterator.hasNext(); + } + + public int getIndex() { + return iterator.index(); + } + } + } + + /** + * A node in the parse tree representing a macro call. If the template contains a definition like + * {@code #macro (mymacro $x $y) ... #end}, then a call of that macro looks like + * {@code #mymacro (xvalue yvalue)}. The call is represented by an instance of this class. The + * definition itself does not appear in the parse tree. + * + * <p>Evaluating a macro involves temporarily setting the parameter variables ({@code $x $y} in + * the example) to thunks representing the argument expressions, evaluating the macro body, and + * restoring any previous values that the parameter variables had. + */ + static class MacroCallNode extends DirectiveNode { + private final String name; + private final ImmutableList<Node> thunks; + private Macro macro; + + MacroCallNode( + String resourceName, + int lineNumber, + String name, + ImmutableList<Node> argumentNodes) { + super(resourceName, lineNumber); + this.name = name; + this.thunks = argumentNodes; + } + + String name() { + return name; + } + + int argumentCount() { + return thunks.size(); + } + + void setMacro(Macro macro) { + this.macro = macro; + } + + @Override + Object evaluate(EvaluationContext context) { + Verify.verifyNotNull(macro, "Macro #%s should have been linked", name); + return macro.evaluate(context, thunks); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/EvaluationContext.java b/src/main/java/com/google/escapevelocity/EvaluationContext.java new file mode 100644 index 0000000..d40b717 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationContext.java @@ -0,0 +1,85 @@ +/* + * 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 com.google.common.collect.ImmutableSet; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.TreeMap; + +/** + * The context of a template evaluation. This consists of the template variables and the template + * macros. The template variables start with the values supplied by the evaluation call, and can + * be changed by {@code #set} directives and during the execution of {@code #foreach} and macro + * calls. The macros are extracted from the template during parsing and never change thereafter. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +interface EvaluationContext { + Object getVar(String var); + + boolean varIsDefined(String var); + + /** + * Sets the given variable to the given value. + * + * @return a Runnable that will restore the variable to the value it had before. If the variable + * was undefined before this method was executed, the Runnable will make it undefined again. + * This allows us to restore the state of {@code $x} after {@code #foreach ($x in ...)}. + */ + Runnable setVar(final String var, Object value); + + /** See {@link MethodFinder#publicMethodsWithName}. */ + ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name); + + class PlainEvaluationContext implements EvaluationContext { + private final Map<String, Object> vars; + private final MethodFinder methodFinder; + + PlainEvaluationContext(Map<String, ?> vars, MethodFinder methodFinder) { + this.vars = new TreeMap<>(vars); + this.methodFinder = methodFinder; + } + + @Override + public Object getVar(String var) { + return vars.get(var); + } + + @Override + public boolean varIsDefined(String var) { + return vars.containsKey(var); + } + + @Override + public Runnable setVar(final String var, Object value) { + Runnable undo; + if (vars.containsKey(var)) { + final Object oldValue = vars.get(var); + undo = () -> vars.put(var, oldValue); + } else { + undo = () -> vars.remove(var); + } + vars.put(var, value); + return undo; + } + + @Override + public ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) { + return methodFinder.publicMethodsWithName(startClass, name); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/EvaluationException.java b/src/main/java/com/google/escapevelocity/EvaluationException.java new file mode 100644 index 0000000..a1f25a4 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationException.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * An exception that occurred while evaluating a template, such as an undefined variable reference + * or a division by zero. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +public class EvaluationException extends RuntimeException { + private static final long serialVersionUID = 1; + + EvaluationException(String message) { + super(message); + } + + EvaluationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/google/escapevelocity/ExpressionNode.java b/src/main/java/com/google/escapevelocity/ExpressionNode.java new file mode 100644 index 0000000..281e998 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ExpressionNode.java @@ -0,0 +1,188 @@ +/* + * 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 com.google.escapevelocity.Parser.Operator; + +/** + * A node in the parse tree representing an expression. Expressions appear inside directives, + * specifically {@code #set}, {@code #if}, {@code #foreach}, and macro calls. Expressions can + * also appear inside indices in references, like {@code $x[$i]}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class ExpressionNode extends Node { + ExpressionNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * True if evaluating this expression yields a value that is considered true by Velocity's + * <a href="http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html#Conditionals"> + * rules</a>. A value is false if it is null or equal to Boolean.FALSE. + * Every other value is true. + * + * <p>Note that the text at the similar link + * <a href="http://velocity.apache.org/engine/devel/user-guide.html#Conditionals">here</a> + * states that empty collections and empty strings are also considered false, but that is not + * true. + */ + boolean isTrue(EvaluationContext context) { + Object value = evaluate(context); + if (value instanceof Boolean) { + return (Boolean) value; + } else { + return value != null; + } + } + + /** + * True if this is a defined value and it evaluates to true. This is the same as {@link #isTrue} + * except that it is allowed for this to be undefined variable, in which it evaluates to false. + * The method is overridden for plain references so that undefined is the same as false. + * The reason is to support Velocity's idiom {@code #if ($var)}, where it is not an error + * if {@code $var} is undefined. + */ + boolean isDefinedAndTrue(EvaluationContext context) { + return isTrue(context); + } + + /** + * The integer result of evaluating this expression. + * + * @throws EvaluationException if evaluating the expression produces an exception, or if it + * yields a value that is not an integer. + */ + int intValue(EvaluationContext context) { + Object value = evaluate(context); + if (!(value instanceof Integer)) { + throw evaluationException("Arithemtic is only available on integers, not " + show(value)); + } + return (Integer) value; + } + + /** + * Returns a string representing the given value, for use in error messages. The string + * includes both the value's {@code toString()} and its type. + */ + private static String show(Object value) { + if (value == null) { + return "null"; + } else { + return value + " (a " + value.getClass().getName() + ")"; + } + } + + /** + * Represents all binary expressions. In {@code #set ($a = $b + $c)}, this will be the type + * of the node representing {@code $b + $c}. + */ + static class BinaryExpressionNode extends ExpressionNode { + final ExpressionNode lhs; + final Operator op; + final ExpressionNode rhs; + + BinaryExpressionNode(ExpressionNode lhs, Operator op, ExpressionNode rhs) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.op = op; + this.rhs = rhs; + } + + @Override Object evaluate(EvaluationContext context) { + switch (op) { + case OR: + return lhs.isTrue(context) || rhs.isTrue(context); + case AND: + return lhs.isTrue(context) && rhs.isTrue(context); + case EQUAL: + return equal(context); + case NOT_EQUAL: + return !equal(context); + default: // fall out + } + int lhsInt = lhs.intValue(context); + int rhsInt = rhs.intValue(context); + switch (op) { + case LESS: + return lhsInt < rhsInt; + case LESS_OR_EQUAL: + return lhsInt <= rhsInt; + case GREATER: + return lhsInt > rhsInt; + case GREATER_OR_EQUAL: + return lhsInt >= rhsInt; + case PLUS: + return lhsInt + rhsInt; + case MINUS: + return lhsInt - rhsInt; + case TIMES: + return lhsInt * rhsInt; + case DIVIDE: + return lhsInt / rhsInt; + case REMAINDER: + return lhsInt % rhsInt; + default: + throw new AssertionError(op); + } + } + + /** + * Returns true if {@code lhs} and {@code rhs} are equal according to Velocity. + * + * <p>Velocity's <a + * href="http://velocity.apache.org/engine/releases/velocity-1.7/vtl-reference-guide.html#aifelseifelse_-_Output_conditional_on_truth_of_statements">definition + * of equality</a> differs depending on whether the objects being compared are of the same + * class. If so, equality comes from {@code Object.equals} as you would expect. But if they + * are not of the same class, they are considered equal if their {@code toString()} values are + * equal. This means that integer 123 equals long 123L and also string {@code "123"}. It also + * means that equality isn't always transitive. For example, two StringBuilder objects each + * containing {@code "123"} will not compare equal, even though the string {@code "123"} + * compares equal to each of them. + */ + private boolean equal(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + Object rhsValue = rhs.evaluate(context); + if (lhsValue == rhsValue) { + return true; + } + if (lhsValue == null || rhsValue == null) { + return false; + } + if (lhsValue.getClass().equals(rhsValue.getClass())) { + return lhsValue.equals(rhsValue); + } + // Funky equals behaviour specified by Velocity. + return lhsValue.toString().equals(rhsValue.toString()); + } + } + + /** + * A node in the parse tree representing an expression like {@code !$a}. + */ + static class NotExpressionNode extends ExpressionNode { + private final ExpressionNode expr; + + NotExpressionNode(ExpressionNode expr) { + super(expr.resourceName, expr.lineNumber); + this.expr = expr; + } + + @Override Object evaluate(EvaluationContext context) { + return !expr.isTrue(context); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/Macro.java b/src/main/java/com/google/escapevelocity/Macro.java new file mode 100644 index 0000000..afa7bf0 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Macro.java @@ -0,0 +1,141 @@ +/* + * 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 com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A macro definition. Macros appear in templates using the syntax {@code #macro (m $x $y) ... #end} + * and each one produces an instance of this class. Evaluating a macro involves setting the + * parameters (here {$x $y)} and evaluating the macro body. Macro arguments are call-by-name, which + * means that we need to set each parameter variable to the node in the parse tree that corresponds + * to it, and arrange for that node to be evaluated when the variable is actually referenced. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Macro { + private final int definitionLineNumber; + private final String name; + private final ImmutableList<String> parameterNames; + private final Node body; + + Macro(int definitionLineNumber, String name, List<String> parameterNames, Node body) { + this.definitionLineNumber = definitionLineNumber; + this.name = name; + this.parameterNames = ImmutableList.copyOf(parameterNames); + this.body = body; + } + + String name() { + return name; + } + + int parameterCount() { + return parameterNames.size(); + } + + Object evaluate(EvaluationContext context, List<Node> thunks) { + try { + Verify.verify(thunks.size() == parameterNames.size(), "Argument mistmatch for %s", name); + Map<String, Node> parameterThunks = new LinkedHashMap<>(); + for (int i = 0; i < parameterNames.size(); i++) { + parameterThunks.put(parameterNames.get(i), thunks.get(i)); + } + EvaluationContext newContext = new MacroEvaluationContext(parameterThunks, context); + return body.evaluate(newContext); + } catch (EvaluationException e) { + EvaluationException newException = new EvaluationException( + "In macro #" + name + " defined on line " + definitionLineNumber + ": " + e.getMessage()); + newException.setStackTrace(e.getStackTrace()); + throw e; + } + } + + /** + * The context for evaluation within macros. This wraps an existing {@code EvaluationContext} + * but intercepts reads of the macro's parameters so that they result in a call-by-name evaluation + * of whatever was passed as the parameter. For example, if you write... + * <pre>{@code + * #macro (mymacro $x) + * $x $x + * #end + * #mymacro($foo.bar(23)) + * }</pre> + * ...then the {@code #mymacro} call will result in {@code $foo.bar(23)} being evaluated twice, + * once for each time {@code $x} appears. The way this works is that {@code $x} is a <i>thunk</i>. + * Historically a thunk is a piece of code to evaluate an expression in the context where it + * occurs, for call-by-name procedures as in Algol 60. Here, it is not exactly a piece of code, + * but it has the same responsibility. + */ + static class MacroEvaluationContext implements EvaluationContext { + private final Map<String, Node> parameterThunks; + private final EvaluationContext originalEvaluationContext; + + MacroEvaluationContext( + Map<String, Node> parameterThunks, EvaluationContext originalEvaluationContext) { + this.parameterThunks = parameterThunks; + this.originalEvaluationContext = originalEvaluationContext; + } + + @Override + public Object getVar(String var) { + Node thunk = parameterThunks.get(var); + if (thunk == null) { + return originalEvaluationContext.getVar(var); + } else { + // Evaluate the thunk in the context where it appeared, not in this context. Otherwise + // if you pass $x to a parameter called $x you would get an infinite recursion. Likewise + // if you had #macro(mymacro $x $y) and a call #mymacro($y 23), you would expect that $x + // would expand to whatever $y meant at the call site, rather than to the value of the $y + // parameter. + return thunk.evaluate(originalEvaluationContext); + } + } + + @Override + public boolean varIsDefined(String var) { + return parameterThunks.containsKey(var) || originalEvaluationContext.varIsDefined(var); + } + + @Override + public Runnable setVar(final String var, Object value) { + // Copy the behaviour that #set will shadow a macro parameter, even though the Velocity peeps + // seem to agree that that is not good. + final Node thunk = parameterThunks.get(var); + if (thunk == null) { + return originalEvaluationContext.setVar(var, value); + } else { + parameterThunks.remove(var); + final Runnable originalUndo = originalEvaluationContext.setVar(var, value); + return () -> { + originalUndo.run(); + parameterThunks.put(var, thunk); + }; + } + } + + @Override + public ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) { + return originalEvaluationContext.publicMethodsWithName(startClass, name); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/MethodFinder.java b/src/main/java/com/google/escapevelocity/MethodFinder.java new file mode 100644 index 0000000..f8f91f5 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/MethodFinder.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2019 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 com.google.common.reflect.Reflection.getPackageName; +import static java.util.stream.Collectors.toSet; + +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Table; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; + +/** + * Finds public methods in a class. For each one, it determines the public class or interface in + * which it is declared. This avoids a problem with reflection, where we get an exception if we call + * a {@code Method} in a non-public class, even if the {@code Method} is public and if there is a + * public ancestor class or interface that declares it. We need to use the {@code Method} from the + * public ancestor. + * + * <p>Because looking for these methods is relatively expensive, an instance of this class will keep + * a cache of methods it previously discovered. + */ +class MethodFinder { + + /** + * For a given class and name, returns all public methods of that name in the class, as previously + * determined by {@link #publicMethodsWithName}. The set of methods for a given class and name is + * saved the first time it is searched for, and returned directly thereafter. It may be empty. + * + * <p>Currently we add the entry for any given (class, name) pair on demand. An alternative would + * be to add all the methods for a given class at once. With the current scheme, we may end up + * calling {@link Class#getMethods()} several times for the same class, if methods of the + * different names are called at different times. With an all-at-once scheme, we might end up + * computing and storing information about a bunch of methods that will never be called. Because + * the profiling that led to the creation of this class revealed that {@link #visibleMethods} in + * particular is quite expensive, it's probably best to avoid calling it unnecessarily. + */ + private final Table<Class<?>, String, ImmutableSet<Method>> methodCache = HashBasedTable.create(); + + /** + * Returns the set of public methods with the given name in the given class. Here, "public + * methods" means public methods in public classes or interfaces. If {@code startClass} is not + * itself public, its methods are effectively not public either, but inherited methods may still + * appear in the returned set, with the {@code Method} objects belonging to public ancestors. More + * than one ancestor may define an appropriate method, but it doesn't matter because invoking any + * of those {@code Method} objects will have the same effect. + */ + synchronized ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) { + ImmutableSet<Method> cachedMethods = methodCache.get(startClass, name); + if (cachedMethods == null) { + cachedMethods = uncachedPublicMethodsWithName(startClass, name); + methodCache.put(startClass, name, cachedMethods); + } + return cachedMethods; + } + + private ImmutableSet<Method> uncachedPublicMethodsWithName(Class<?> startClass, String name) { + // Class.getMethods() only returns public methods, so no need to filter explicitly for public. + Set<Method> methods = + Arrays.stream(startClass.getMethods()) + .filter(m -> m.getName().equals(name)) + .collect(toSet()); + if (!classIsPublic(startClass)) { + methods = + methods.stream() + .map(m -> visibleMethod(m, startClass)) + .filter(Objects::nonNull) + .collect(toSet()); + // It would be a bit simpler to use ImmutableSet.toImmutableSet() here, but there've been + // problems in the past with versions of Guava that don't have that method. + } + return ImmutableSet.copyOf(methods); + } + + private static final String THIS_PACKAGE = getPackageName(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 public methods of $foreach without + // having to make the ForEachVar class public. We can invoke those methods from the same + // package since ForEachVar is package-protected. + return methodInClass; + } + Method methodInSuperclass = visibleMethod(method, in.getSuperclass()); + if (methodInSuperclass != null) { + return methodInSuperclass; + } + for (Class<?> superinterface : in.getInterfaces()) { + Method methodInSuperinterface = visibleMethod(method, superinterface); + if (methodInSuperinterface != null) { + return methodInSuperinterface; + } + } + 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) { + return Modifier.isPublic(c.getModifiers()) && classIsExported(c); + } + + private static boolean classIsExported(Class<?> c) { + if (CLASS_GET_MODULE_METHOD == null) { + return true; // There are no modules, so all classes are exported. + } + try { + String pkg = getPackageName(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; + } +} diff --git a/src/main/java/com/google/escapevelocity/Node.java b/src/main/java/com/google/escapevelocity/Node.java new file mode 100644 index 0000000..a017afa --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Node.java @@ -0,0 +1,92 @@ +/* + * 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 com.google.common.collect.ImmutableList; + +/** + * A node in the parse tree. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class Node { + final String resourceName; + final int lineNumber; + + Node(String resourceName, int lineNumber) { + this.resourceName = resourceName; + this.lineNumber = lineNumber; + } + + /** + * Returns the result of evaluating this node in the given context. This result may be used as + * part of a further operation, for example evaluating {@code 2 + 3} to 5 in order to set + * {@code $x} to 5 in {@code #set ($x = 2 + 3)}. Or it may be used directly as part of the + * template output, for example evaluating replacing {@code name} by {@code Fred} in + * {@code My name is $name.}. + */ + abstract Object evaluate(EvaluationContext context); + + private String where() { + String where = "In expression on line " + lineNumber; + if (resourceName != null) { + where += " of " + resourceName; + } + return where; + } + + EvaluationException evaluationException(String message) { + return new EvaluationException(where() + ": " + message); + } + + EvaluationException evaluationException(Throwable cause) { + return new EvaluationException(where() + ": " + cause, cause); + } + + /** + * Returns an empty node in the parse tree. This is used for example to represent the trivial + * "else" part of an {@code #if} that does not have an explicit {@code #else}. + */ + static Node emptyNode(String resourceName, int lineNumber) { + return new Cons(resourceName, lineNumber, ImmutableList.<Node>of()); + } + + /** + * Create a new parse tree node that is the concatenation of the given ones. Evaluating the + * new node produces the same string as evaluating each of the given nodes and concatenating the + * result. + */ + static Node cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) { + return new Cons(resourceName, lineNumber, nodes); + } + + private static final class Cons extends Node { + private final ImmutableList<Node> nodes; + + Cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) { + super(resourceName, lineNumber); + this.nodes = nodes; + } + + @Override Object evaluate(EvaluationContext context) { + StringBuilder sb = new StringBuilder(); + for (Node node : nodes) { + sb.append(node.evaluate(context)); + } + return sb.toString(); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/ParseException.java b/src/main/java/com/google/escapevelocity/ParseException.java new file mode 100644 index 0000000..7105f97 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ParseException.java @@ -0,0 +1,41 @@ +/* + * 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; + +/** + * An exception that occurred while parsing a template. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +public class ParseException extends RuntimeException { + private static final long serialVersionUID = 1; + + ParseException(String message, String resourceName, int lineNumber) { + super(message + ", " + where(resourceName, lineNumber)); + } + + ParseException(String message, String resourceName, int lineNumber, String context) { + super(message + ", " + where(resourceName, lineNumber) + ", at text starting: " + context); + } + + private static String where(String resourceName, int lineNumber) { + if (resourceName == null) { + return "on line " + lineNumber; + } else { + return "on line " + lineNumber + " of " + resourceName; + } + } +} diff --git a/src/main/java/com/google/escapevelocity/Parser.java b/src/main/java/com/google/escapevelocity/Parser.java new file mode 100644 index 0000000..4416c48 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Parser.java @@ -0,0 +1,1094 @@ +/* + * 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 com.google.common.base.CharMatcher; +import com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.Iterables; +import com.google.common.primitives.Chars; +import com.google.common.primitives.Ints; +import com.google.escapevelocity.DirectiveNode.SetNode; +import com.google.escapevelocity.ExpressionNode.BinaryExpressionNode; +import com.google.escapevelocity.ExpressionNode.NotExpressionNode; +import com.google.escapevelocity.ReferenceNode.IndexReferenceNode; +import com.google.escapevelocity.ReferenceNode.MemberReferenceNode; +import com.google.escapevelocity.ReferenceNode.MethodReferenceNode; +import com.google.escapevelocity.ReferenceNode.PlainReferenceNode; +import com.google.escapevelocity.TokenNode.CommentTokenNode; +import com.google.escapevelocity.TokenNode.ElseIfTokenNode; +import com.google.escapevelocity.TokenNode.ElseTokenNode; +import com.google.escapevelocity.TokenNode.EndTokenNode; +import com.google.escapevelocity.TokenNode.EofNode; +import com.google.escapevelocity.TokenNode.ForEachTokenNode; +import com.google.escapevelocity.TokenNode.IfTokenNode; +import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode; +import com.google.escapevelocity.TokenNode.NestedTokenNode; +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.Reader; + +/** + * A parser that reads input from the given {@link Reader} and parses it to produce a + * {@link Template}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Parser { + private static final int EOF = -1; + + private final LineNumberReader reader; + private final String resourceName; + private final Template.ResourceOpener resourceOpener; + + /** + * The invariant of this parser is that {@code c} is always the next character of interest. + * This means that we almost never have to "unget" a character by reading too far. For example, + * after we parse an integer, {@code c} will be the first character after the integer, which is + * exactly the state we will be in when there are no more digits. + * + * <p>Sometimes we need to read two characters ahead, and in that case we use {@link #pushback}. + */ + private int c; + + /** + * A single character of pushback. If this is not negative, the {@link #next()} method will + * return it instead of reading a character. + */ + private int pushback = -1; + + Parser(Reader reader, String resourceName, Template.ResourceOpener resourceOpener) + throws IOException { + this.reader = new LineNumberReader(reader); + this.reader.setLineNumber(1); + next(); + this.resourceName = resourceName; + this.resourceOpener = resourceOpener; + } + + /** + * Parse the input completely to produce a {@link Template}. + * + * <p>Parsing happens in two phases. First, we parse a sequence of "tokens", where tokens include + * entire references such as <pre> + * ${x.foo()[23]} + * </pre>or entire directives such as<pre> + * #set ($x = $y + $z) + * </pre>But tokens do not span complex constructs. For example,<pre> + * #if ($x == $y) something #end + * </pre>is three tokens:<pre> + * #if ($x == $y) + * (literal text " something ") + * #end + * </pre> + * + * <p>The second phase then takes the sequence of tokens and constructs a parse tree out of it. + * Some nodes in the parse tree will be unchanged from the token sequence, such as the <pre> + * ${x.foo()[23]} + * #set ($x = $y + $z) + * </pre> examples above. But a construct such as the {@code #if ... #end} mentioned above will + * become a single IfNode in the parse tree in the second phase. + * + * <p>The main reason for this approach is that Velocity has two kinds of lexical contexts. At the + * top level, there can be arbitrary literal text; references like <code>${x.foo()}</code>; and + * directives like {@code #if} or {@code #set}. Inside the parentheses of a directive, however, + * neither arbitrary text nor directives can appear, but expressions can, so we need to tokenize + * the inside of <pre> + * #if ($x == $a + $b) + * </pre> as the five tokens "$x", "==", "$a", "+", "$b". Rather than having a classical + * parser/lexer combination, where the lexer would need to switch between these two modes, we + * replace the lexer with an ad-hoc parser that is the first phase described above, and we + * define a simple parser over the resultant tokens that is the second phase. + */ + Template parse() throws IOException { + ImmutableList<Node> tokens = parseTokens(); + return new Reparser(tokens).reparse(); + } + + private ImmutableList<Node> parseTokens() throws IOException { + ImmutableList.Builder<Node> tokens = ImmutableList.builder(); + Node token; + do { + token = parseNode(); + tokens.add(token); + } while (!(token instanceof EofNode)); + return tokens.build(); + } + + private int lineNumber() { + return reader.getLineNumber(); + } + + /** + * Gets the next character from the reader and assigns it to {@code c}. If there are no more + * characters, sets {@code c} to {@link #EOF} if it is not already. + */ + private void next() throws IOException { + if (c != EOF) { + if (pushback < 0) { + c = reader.read(); + } else { + c = pushback; + pushback = -1; + } + } + } + + /** + * Saves the current character {@code c} to be read again, and sets {@code c} to the given + * {@code c1}. Suppose the text contains {@code xy} and we have just read {@code y}. + * So {@code c == 'y'}. Now if we execute {@code pushback('x')}, we will have + * {@code c == 'x'} and the next call to {@link #next()} will set {@code c == 'y'}. Subsequent + * calls to {@code next()} will continue reading from {@link #reader}. So the pushback + * essentially puts us back in the state we were in before we read {@code y}. + */ + private void pushback(int c1) { + pushback = c; + c = c1; + } + + /** + * If {@code c} is a space character, keeps reading until {@code c} is a non-space character or + * there are no more characters. + */ + private void skipSpace() throws IOException { + while (Character.isWhitespace(c)) { + next(); + } + } + + /** + * Gets the next character from the reader, and if it is a space character, keeps reading until + * a non-space character is found. + */ + private void nextNonSpace() throws IOException { + next(); + skipSpace(); + } + + /** + * Skips any space in the reader, and then throws an exception if the first non-space character + * found is not the expected one. Sets {@code c} to the first character after that expected one. + */ + private void expect(char expected) throws IOException { + skipSpace(); + if (c == expected) { + next(); + } else { + throw parseException("Expected " + expected); + } + } + + /** + * Parses a single node from the reader, as part of the first parsing phase. + * <pre>{@code + * <template> -> <empty> | + * <directive> <template> | + * <non-directive> <template> + * }</pre> + */ + private Node parseNode() throws IOException { + if (c == '#') { + next(); + switch (c) { + case '#': + return parseLineComment(); + case '*': + return parseBlockComment(); + case '[': + return parseHashSquare(); + case '{': + return parseDirective(); + default: + if (isAsciiLetter(c)) { + return parseDirective(); + } else { + // For consistency with Velocity, we treat # not followed by a letter or one of the + // characters above as a plain character, and we treat #$foo as a literal # followed by + // the reference $foo. + return parsePlainText('#'); + } + } + } + if (c == EOF) { + return new EofNode(resourceName, lineNumber()); + } + return parseNonDirective(); + } + + private Node parseHashSquare() throws IOException { + // We've just seen #[ which might be the start of a #[[quoted block]]#. If the next character + // is not another [ then it's not a quoted block, but it *is* a literal #[ followed by whatever + // that next character is. + assert c == '['; + next(); + if (c != '[') { + return parsePlainText(new StringBuilder("#[")); + } + int startLine = lineNumber(); + next(); + StringBuilder sb = new StringBuilder(); + while (true) { + if (c == EOF) { + throw new ParseException( + "Unterminated #[[ - did not see matching ]]#", resourceName, startLine); + } + if (c == '#') { + // This might be the last character of ]]# or it might just be a random #. + int len = sb.length(); + if (len > 1 && sb.charAt(len - 1) == ']' && sb.charAt(len - 2) == ']') { + next(); + break; + } + } + sb.append((char) c); + next(); + } + String quoted = sb.substring(0, sb.length() - 2); + return new ConstantExpressionNode(resourceName, lineNumber(), quoted); + } + + /** + * Parses a single non-directive node from the reader. + * <pre>{@code + * <non-directive> -> <reference> | + * <text containing neither $ nor #> + * }</pre> + */ + private Node parseNonDirective() throws IOException { + if (c == '$') { + next(); + if (isAsciiLetter(c) || c == '{') { + return parseReference(); + } else { + return parsePlainText('$'); + } + } else { + int firstChar = c; + next(); + return parsePlainText(firstChar); + } + } + + /** + * Parses a single directive token from the reader. Directives can be spelled with or without + * braces, for example {@code #if} or {@code #{if}}. We omit the brace spelling in the productions + * here: <pre>{@code + * <directive> -> <if-token> | + * <else-token> | + * <elseif-token> | + * <end-token> | + * <foreach-token> | + * <set-token> | + * <parse-token> | + * <macro-token> | + * <macro-call> | + * <comment> + * }</pre> + */ + private Node parseDirective() throws IOException { + String directive; + if (c == '{') { + next(); + directive = parseId("Directive inside #{...}"); + expect('}'); + } else { + directive = parseId("Directive"); + } + Node node; + switch (directive) { + case "end": + node = new EndTokenNode(resourceName, lineNumber()); + break; + case "if": + case "elseif": + node = parseIfOrElseIf(directive); + break; + case "else": + node = new ElseTokenNode(resourceName, lineNumber()); + break; + case "foreach": + node = parseForEach(); + break; + case "set": + node = parseSet(); + break; + case "parse": + node = parseParse(); + break; + case "macro": + node = parseMacroDefinition(); + break; + default: + node = parsePossibleMacroCall(directive); + } + // Velocity skips a newline after any directive. + // TODO(emcmanus): in fact it also skips space before the newline, which should be implemented. + if (c == '\n') { + next(); + } + return node; + } + + /** + * Parses the condition following {@code #if} or {@code #elseif}. + * <pre>{@code + * <if-token> -> #if ( <condition> ) + * <elseif-token> -> #elseif ( <condition> ) + * }</pre> + * + * @param directive either {@code "if"} or {@code "elseif"}. + */ + private Node parseIfOrElseIf(String directive) throws IOException { + expect('('); + ExpressionNode condition = parseExpression(); + expect(')'); + return directive.equals("if") ? new IfTokenNode(condition) : new ElseIfTokenNode(condition); + } + + /** + * Parses a {@code #foreach} token from the reader. <pre>{@code + * <foreach-token> -> #foreach ( $<id> in <expression> ) + * }</pre> + */ + private Node parseForEach() throws IOException { + expect('('); + expect('$'); + String var = parseId("For-each variable"); + skipSpace(); + boolean bad = false; + if (c != 'i') { + bad = true; + } else { + next(); + if (c != 'n') { + bad = true; + } + } + if (bad) { + throw parseException("Expected 'in' for #foreach"); + } + next(); + ExpressionNode collection = parseExpression(); + expect(')'); + return new ForEachTokenNode(var, collection); + } + + /** + * Parses a {@code #set} token from the reader. <pre>{@code + * <set-token> -> #set ( $<id> = <expression>) + * }</pre> + */ + private Node parseSet() throws IOException { + expect('('); + expect('$'); + String var = parseId("#set variable"); + expect('='); + ExpressionNode expression = parseExpression(); + expect(')'); + return new SetNode(var, expression); + } + + /** + * Parses a {@code #parse} token from the reader. <pre>{@code + * <parse-token> -> #parse ( <string-literal> ) + * }</pre> + * + * <p>The way this works is inconsistent with Velocity. In Velocity, the {@code #parse} directive + * is evaluated when it is encountered during template evaluation. That means that the argument + * can be a variable, and it also means that you can use {@code #if} to choose whether or not + * to do the {@code #parse}. Neither of those is true in EscapeVelocity. The contents of the + * {@code #parse} are integrated into the containing template pretty much as if they had been + * written inline. That also means that EscapeVelocity allows forward references to macros + * inside {@code #parse} directives, which Velocity does not. + */ + private Node parseParse() throws IOException { + expect('('); + skipSpace(); + if (c != '"' && c != '\'') { + throw parseException("#parse only supported with string literal argument"); + } + ExpressionNode nestedResourceNameExpression = parseStringLiteral(c, false); + String nestedResourceName = nestedResourceNameExpression.evaluate(null).toString(); + expect(')'); + try (Reader nestedReader = resourceOpener.openResource(nestedResourceName)) { + Parser nestedParser = new Parser(nestedReader, nestedResourceName, resourceOpener); + ImmutableList<Node> nestedTokens = nestedParser.parseTokens(); + return new NestedTokenNode(nestedResourceName, nestedTokens); + } + } + + /** + * Parses a {@code #macro} token from the reader. <pre>{@code + * <macro-token> -> #macro ( <id> <macro-parameter-list> ) + * <macro-parameter-list> -> <empty> | + * $<id> <macro-parameter-list> + * }</pre> + * + * <p>Macro parameters are optionally separated by commas. + */ + private Node parseMacroDefinition() throws IOException { + expect('('); + skipSpace(); + String name = parseId("Macro name"); + ImmutableList.Builder<String> parameterNames = ImmutableList.builder(); + while (true) { + skipSpace(); + if (c == ')') { + next(); + break; + } + if (c == ',') { + next(); + skipSpace(); + } + if (c != '$') { + throw parseException("Macro parameters should look like $name"); + } + next(); + parameterNames.add(parseId("Macro parameter name")); + } + return new MacroDefinitionTokenNode(resourceName, lineNumber(), name, parameterNames.build()); + } + + /** + * Parses an identifier after {@code #} that is not one of the standard directives. The assumption + * is that it is a call of a macro that is defined in the template. Macro definitions are + * extracted from the template during the second parsing phase (and not during evaluation of the + * template as you might expect). This means that a macro can be called before it is defined. + * <pre>{@code + * <macro-call> -> # <id> ( <expression-list> ) + * <expression-list> -> <empty> | + * <expression> <optional-comma> <expression-list> + * <optional-comma> -> <empty> | , + * }</pre> + */ + private Node parsePossibleMacroCall(String directive) throws IOException { + skipSpace(); + if (c != '(') { + throw parseException("Unrecognized directive #" + directive); + } + next(); + ImmutableList.Builder<Node> parameterNodes = ImmutableList.builder(); + while (true) { + skipSpace(); + if (c == ')') { + next(); + break; + } + parameterNodes.add(parsePrimary()); + if (c == ',') { + // The documentation doesn't say so, but you can apparently have an optional comma in + // macro calls. + next(); + } + } + return new DirectiveNode.MacroCallNode( + resourceName, lineNumber(), directive, parameterNodes.build()); + } + + /** + * Parses and discards a line comment, which is {@code ##} followed by any number of characters + * up to and including the next newline. + */ + private Node parseLineComment() throws IOException { + int lineNumber = lineNumber(); + while (c != '\n' && c != EOF) { + next(); + } + next(); + return new CommentTokenNode(resourceName, lineNumber); + } + + /** + * Parses and discards a block comment, which is {@code #*} followed by everything up to and + * including the next {@code *#}. + */ + private Node parseBlockComment() throws IOException { + assert c == '*'; + int startLine = lineNumber(); + int lastC = '\0'; + next(); + // Consistently with Velocity, we do not make it an error if a #* comment is not closed. + while (!(lastC == '*' && c == '#') && c != EOF) { + lastC = c; + next(); + } + next(); // this may read EOF twice, which works + return new CommentTokenNode(resourceName, startLine); + } + + /** + * Parses plain text, which is text that contains neither {@code $} nor {@code #}. The given + * {@code firstChar} is the first character of the plain text, and {@link #c} is the second + * (if the plain text is more than one character). + */ + private Node parsePlainText(int firstChar) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.appendCodePoint(firstChar); + return parsePlainText(sb); + } + + private Node parsePlainText(StringBuilder sb) throws IOException { + literal: + while (true) { + switch (c) { + case EOF: + case '$': + case '#': + break literal; + default: + // Just some random character. + } + sb.appendCodePoint(c); + next(); + } + return new ConstantExpressionNode(resourceName, lineNumber(), sb.toString()); + } + + /** + * Parses a reference, which is everything that can start with a {@code $}. References can + * optionally be enclosed in braces, so {@code $x} and {@code ${x}} are the same. Braces are + * useful when text after the reference would otherwise be parsed as part of it. For example, + * {@code ${x}y} is a reference to the variable {@code $x}, followed by the plain text {@code y}. + * Of course {@code $xy} would be a reference to the variable {@code $xy}. + * <pre>{@code + * <reference> -> $<reference-no-brace> | + * ${<reference-no-brace>} + * }</pre> + * + * <p>On entry to this method, {@link #c} is the character immediately after the {@code $}. + */ + private Node parseReference() throws IOException { + if (c == '{') { + next(); + if (!isAsciiLetter(c)) { + return parsePlainText(new StringBuilder("${")); + } + ReferenceNode node = parseReferenceNoBrace(); + expect('}'); + return node; + } else { + return parseReferenceNoBrace(); + } + } + + /** + * Same as {@link #parseReference()}, except it really must be a reference. A {@code $} in + * normal text doesn't start a reference if it is not followed by an identifier. But in an + * expression, for example in {@code #if ($x == 23)}, {@code $} must be followed by an + * identifier. + */ + private ReferenceNode parseRequiredReference() throws IOException { + if (c == '{') { + next(); + ReferenceNode node = parseReferenceNoBrace(); + expect('}'); + return node; + } else { + return parseReferenceNoBrace(); + } + } + + /** + * Parses a reference, in the simple form without braces. + * <pre>{@code + * <reference-no-brace> -> <id><reference-suffix> + * }</pre> + */ + private ReferenceNode parseReferenceNoBrace() throws IOException { + String id = parseId("Reference"); + ReferenceNode lhs = new PlainReferenceNode(resourceName, lineNumber(), id); + return parseReferenceSuffix(lhs); + } + + /** + * Parses the modifiers that can appear at the tail of a reference. + * <pre>{@code + * <reference-suffix> -> <empty> | + * <reference-member> | + * <reference-index> + * }</pre> + * + * @param lhs the reference node representing the first part of the reference + * {@code $x} in {@code $x.foo} or {@code $x.foo()}, or later {@code $x.y} in {@code $x.y.z}. + */ + private ReferenceNode parseReferenceSuffix(ReferenceNode lhs) throws IOException { + switch (c) { + case '.': + return parseReferenceMember(lhs); + case '[': + return parseReferenceIndex(lhs); + default: + return lhs; + } + } + + /** + * Parses a reference member, which is either a property reference like {@code $x.y} or a method + * call like {@code $x.y($z)}. + * <pre>{@code + * <reference-member> -> .<id><reference-property-or-method><reference-suffix> + * <reference-property-or-method> -> <id> | + * <id> ( <method-parameter-list> ) + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x.foo} or {@code $x.foo()}. + */ + private ReferenceNode parseReferenceMember(ReferenceNode lhs) throws IOException { + assert c == '.'; + next(); + if (!isAsciiLetter(c)) { + // We've seen something like `$foo.!`, so it turns out it's not a member after all. + pushback('.'); + return lhs; + } + String id = parseId("Member"); + ReferenceNode reference; + if (c == '(') { + reference = parseReferenceMethodParams(lhs, id); + } else { + reference = new MemberReferenceNode(lhs, id); + } + return parseReferenceSuffix(reference); + } + + /** + * Parses the parameters to a method reference, like {@code $foo.bar($a, $b)}. + * <pre>{@code + * <method-parameter-list> -> <empty> | + * <non-empty-method-parameter-list> + * <non-empty-method-parameter-list> -> <expression> | + * <expression> , <non-empty-method-parameter-list> + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x.foo()}. + */ + private ReferenceNode parseReferenceMethodParams(ReferenceNode lhs, String id) + throws IOException { + assert c == '('; + nextNonSpace(); + ImmutableList.Builder<ExpressionNode> args = ImmutableList.builder(); + if (c != ')') { + args.add(parseExpression()); + while (c == ',') { + nextNonSpace(); + args.add(parseExpression()); + } + if (c != ')') { + throw parseException("Expected )"); + } + } + assert c == ')'; + next(); + return new MethodReferenceNode(lhs, id, args.build()); + } + + /** + * Parses an index suffix to a method, like {@code $x[$i]}. + * <pre>{@code + * <reference-index> -> [ <expression> ] + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x[$i]}. + */ + private ReferenceNode parseReferenceIndex(ReferenceNode lhs) throws IOException { + assert c == '['; + next(); + ExpressionNode index = parseExpression(); + if (c != ']') { + throw parseException("Expected ]"); + } + next(); + ReferenceNode reference = new IndexReferenceNode(lhs, index); + return parseReferenceSuffix(reference); + } + + enum Operator { + /** + * A dummy operator with low precedence. When parsing subexpressions, we always stop when we + * reach an operator of lower precedence than the "current precedence". For example, when + * parsing {@code 1 + 2 * 3 + 4}, we'll stop parsing the subexpression {@code * 3 + 4} when + * we reach the {@code +} because it has lower precedence than {@code *}. This dummy operator, + * then, behaves like {@code +} when the minimum precedence is {@code *}. We also return it + * if we're looking for an operator and don't find one. If this operator is {@code ⊙}, it's as + * if our expressions are bracketed with it, like {@code ⊙ 1 + 2 * 3 + 4 ⊙}. + */ + STOP("", 0), + + // If a one-character operator is a prefix of a two-character operator, like < and <=, then + // the one-character operator must come first. + OR("||", 1), + AND("&&", 2), + EQUAL("==", 3), NOT_EQUAL("!=", 3), + LESS("<", 4), LESS_OR_EQUAL("<=", 4), GREATER(">", 4), GREATER_OR_EQUAL(">=", 4), + PLUS("+", 5), MINUS("-", 5), + TIMES("*", 6), DIVIDE("/", 6), REMAINDER("%", 6); + + final String symbol; + final int precedence; + + Operator(String symbol, int precedence) { + this.symbol = symbol; + this.precedence = precedence; + } + + @Override + public String toString() { + return symbol; + } + } + + /** + * Maps a code point to the operators that begin with that code point. For example, maps + * {@code <} to {@code LESS} and {@code LESS_OR_EQUAL}. + */ + private static final ImmutableListMultimap<Integer, Operator> CODE_POINT_TO_OPERATORS; + static { + ImmutableListMultimap.Builder<Integer, Operator> builder = ImmutableListMultimap.builder(); + for (Operator operator : Operator.values()) { + if (operator != Operator.STOP) { + builder.put((int) operator.symbol.charAt(0), operator); + } + } + CODE_POINT_TO_OPERATORS = builder.build(); + } + + /** + * Parses an expression, which can occur within a directive like {@code #if} or {@code #set}, + * or within a reference like {@code $x[$a + $b]} or {@code $x.m($a + $b)}. + * <pre>{@code + * <expression> -> <and-expression> | + * <expression> || <and-expression> + * <and-expression> -> <relational-expression> | + * <and-expression> && <relational-expression> + * <equality-exression> -> <relational-expression> | + * <equality-expression> <equality-op> <relational-expression> + * <equality-op> -> == | != + * <relational-expression> -> <additive-expression> | + * <relational-expression> <relation> <additive-expression> + * <relation> -> < | <= | > | >= + * <additive-expression> -> <multiplicative-expression> | + * <additive-expression> <add-op> <multiplicative-expression> + * <add-op> -> + | - + * <multiplicative-expression> -> <unary-expression> | + * <multiplicative-expression> <mult-op> <unary-expression> + * <mult-op> -> * | / | % + * }</pre> + */ + private ExpressionNode parseExpression() throws IOException { + ExpressionNode lhs = parseUnaryExpression(); + return new OperatorParser().parse(lhs, 1); + } + + /** + * An operator-precedence parser for the binary operations we understand. It implements an + * <a href="http://en.wikipedia.org/wiki/Operator-precedence_parser">algorithm</a> from Wikipedia + * that uses recursion rather than having an explicit stack of operators and values. + */ + private class OperatorParser { + /** + * The operator we have just scanned, in the same way that {@link #c} is the character we have + * just read. If we were not able to scan an operator, this will be {@link Operator#STOP}. + */ + private Operator currentOperator; + + OperatorParser() throws IOException { + nextOperator(); + } + + /** + * Parse a subexpression whose left-hand side is {@code lhs} and where we only consider + * operators with precedence at least {@code minPrecedence}. + * + * @return the parsed subexpression + */ + ExpressionNode parse(ExpressionNode lhs, int minPrecedence) throws IOException { + while (currentOperator.precedence >= minPrecedence) { + Operator operator = currentOperator; + ExpressionNode rhs = parseUnaryExpression(); + nextOperator(); + while (currentOperator.precedence > operator.precedence) { + rhs = parse(rhs, currentOperator.precedence); + } + lhs = new BinaryExpressionNode(lhs, operator, rhs); + } + return lhs; + } + + /** + * Updates {@link #currentOperator} to be an operator read from the input, + * or {@link Operator#STOP} if there is none. + */ + private void nextOperator() throws IOException { + skipSpace(); + ImmutableList<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c); + if (possibleOperators.isEmpty()) { + currentOperator = Operator.STOP; + return; + } + char firstChar = Chars.checkedCast(c); + next(); + Operator operator = null; + for (Operator possibleOperator : possibleOperators) { + if (possibleOperator.symbol.length() == 1) { + Verify.verify(operator == null); + operator = possibleOperator; + } else if (possibleOperator.symbol.charAt(1) == c) { + next(); + operator = possibleOperator; + } + } + if (operator == null) { + throw parseException( + "Expected " + Iterables.getOnlyElement(possibleOperators) + ", not just " + firstChar); + } + currentOperator = operator; + } + } + + /** + * Parses an expression not containing any operators (except inside parentheses). + * <pre>{@code + * <unary-expression> -> <primary> | + * ( <expression> ) | + * ! <unary-expression> + * }</pre> + */ + private ExpressionNode parseUnaryExpression() throws IOException { + skipSpace(); + ExpressionNode node; + if (c == '(') { + nextNonSpace(); + node = parseExpression(); + expect(')'); + skipSpace(); + return node; + } else if (c == '!') { + next(); + node = new NotExpressionNode(parseUnaryExpression()); + skipSpace(); + return node; + } else { + return parsePrimary(); + } + } + + /** + * Parses an expression containing only literals or references. + * <pre>{@code + * <primary> -> <reference> | + * <string-literal> | + * <integer-literal> | + * <boolean-literal> + * }</pre> + */ + private ExpressionNode parsePrimary() throws IOException { + ExpressionNode node; + if (c == '$') { + next(); + node = parseRequiredReference(); + } else if (c == '"') { + node = parseStringLiteral(c, true); + } else if (c == '\'') { + node = parseStringLiteral(c, false); + } else if (c == '-') { + // Velocity does not have a negation operator. If we see '-' it must be the start of a + // negative integer literal. + next(); + node = parseIntLiteral("-"); + } else if (isAsciiDigit(c)) { + node = parseIntLiteral(""); + } else if (isAsciiLetter(c)) { + node = parseBooleanLiteral(); + } else { + throw parseException("Expected an expression"); + } + skipSpace(); + return node; + } + + /** + * Parses a string literal, which may contain references to be expanded. Examples are + * {@code "foo"} or {@code "foo${bar}baz"}. + * <pre>{@code + * <string-literal> -> <double-quote-literal> | <single-quote-literal> + * <double-quote-literal> -> " <double-quote-string-contents> " + * <double-quote-string-contents> -> <empty> | + * <reference> <double-quote-string-contents> | + * <character-other-than-"> <double-quote-string-contents> + * <single-quote-literal> -> ' <single-quote-string-contents> ' + * <single-quote-string-contents> -> <empty> | + * <character-other-than-'> <single-quote-string-contents> + * }</pre> + */ + private ExpressionNode parseStringLiteral(int quote, boolean allowReferences) + throws IOException { + assert c == quote; + next(); + ImmutableList.Builder<Node> nodes = ImmutableList.builder(); + StringBuilder sb = new StringBuilder(); + while (c != quote) { + switch (c) { + case '\n': + case EOF: + throw parseException("Unterminated string constant"); + case '\\': + throw parseException( + "Escapes in string constants are not currently supported"); + case '$': + if (allowReferences) { + if (sb.length() > 0) { + nodes.add(new ConstantExpressionNode(resourceName, lineNumber(), sb.toString())); + sb.setLength(0); + } + next(); + nodes.add(parseReference()); + break; + } + // fall through + default: + sb.appendCodePoint(c); + next(); + } + } + next(); + if (sb.length() > 0) { + nodes.add(new ConstantExpressionNode(resourceName, lineNumber(), sb.toString())); + } + return new StringLiteralNode(resourceName, lineNumber(), nodes.build()); + } + + private static class StringLiteralNode extends ExpressionNode { + private final ImmutableList<Node> nodes; + + StringLiteralNode(String resourceName, int lineNumber, ImmutableList<Node> nodes) { + super(resourceName, lineNumber); + this.nodes = nodes; + } + + @Override + Object evaluate(EvaluationContext context) { + StringBuilder sb = new StringBuilder(); + for (Node node : nodes) { + sb.append(node.evaluate(context)); + } + return sb.toString(); + } + } + + private ExpressionNode parseIntLiteral(String prefix) throws IOException { + StringBuilder sb = new StringBuilder(prefix); + while (isAsciiDigit(c)) { + sb.appendCodePoint(c); + next(); + } + Integer value = Ints.tryParse(sb.toString()); + if (value == null) { + throw parseException("Invalid integer: " + sb); + } + return new ConstantExpressionNode(resourceName, lineNumber(), value); + } + + /** + * Parses a boolean literal, either {@code true} or {@code false}. + * <boolean-literal> -> true | + * false + */ + private ExpressionNode parseBooleanLiteral() throws IOException { + String s = parseId("Identifier without $"); + boolean value; + if (s.equals("true")) { + value = true; + } else if (s.equals("false")) { + value = false; + } else { + throw parseException("Identifier in expression must be preceded by $ or be true or false"); + } + return new ConstantExpressionNode(resourceName, lineNumber(), value); + } + + private static final CharMatcher ASCII_LETTER = + CharMatcher.inRange('A', 'Z') + .or(CharMatcher.inRange('a', 'z')) + .precomputed(); + + private static final CharMatcher ASCII_DIGIT = + CharMatcher.inRange('0', '9') + .precomputed(); + + private static final CharMatcher ID_CHAR = + ASCII_LETTER + .or(ASCII_DIGIT) + .or(CharMatcher.anyOf("-_")) + .precomputed(); + + private static boolean isAsciiLetter(int c) { + return (char) c == c && ASCII_LETTER.matches((char) c); + } + + private static boolean isAsciiDigit(int c) { + return (char) c == c && ASCII_DIGIT.matches((char) c); + } + + private static boolean isIdChar(int c) { + return (char) c == c && ID_CHAR.matches((char) c); + } + + /** + * Parse an identifier as specified by the + * <a href="http://velocity.apache.org/engine/devel/vtl-reference-guide.html#Variables">VTL + * </a>. Identifiers are ASCII: starts with a letter, then letters, digits, {@code -} and + * {@code _}. + */ + private String parseId(String what) throws IOException { + if (!isAsciiLetter(c)) { + throw parseException(what + " should start with an ASCII letter"); + } + StringBuilder id = new StringBuilder(); + while (isIdChar(c)) { + id.appendCodePoint(c); + next(); + } + return id.toString(); + } + + /** + * Returns an exception to be thrown describing a parse error with the given message, and + * including information about where it occurred. + */ + private ParseException parseException(String message) throws IOException { + StringBuilder context = new StringBuilder(); + if (c == EOF) { + context.append("EOF"); + } else { + int count = 0; + while (c != EOF && count < 20) { + context.appendCodePoint(c); + next(); + count++; + } + if (c != EOF) { + context.append("..."); + } + } + return new ParseException(message, resourceName, lineNumber(), context.toString()); + } +} 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..622388f --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java @@ -0,0 +1,332 @@ +/* + * 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 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"); + } + // 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<Method> 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 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"); + } + 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<Object> argValues = args.stream() + .map(arg -> arg.evaluate(context)) + .collect(toList()); + ImmutableSet<Method> publicMethodsWithName = context.publicMethodsWithName(targetClass, id); + if (publicMethodsWithName.isEmpty()) { + throw evaluationException("No method " + id + " in " + targetClass.getName()); + } + List<Method> compatibleMethods = publicMethodsWithName.stream() + .filter(method -> compatibleArgs(method.getParameterTypes(), argValues)) + .collect(toList()); + // TODO(emcmanus): support varargs, if it's useful + if (compatibleMethods.size() > 1) { + compatibleMethods = + compatibleMethods.stream().filter(method -> !method.isSynthetic()).collect(toList()); + } + switch (compatibleMethods.size()) { + case 0: + throw evaluationException( + "Parameters for method " + id + " have wrong types: " + argValues); + case 1: + return invokeMethod(Iterables.getOnlyElement(compatibleMethods), lhsValue, argValues); + default: + throw evaluationException( + "Ambiguous method invocation, could be one of:\n " + + Joiner.on("\n ").join(compatibleMethods)); + } + } + + /** + * 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 (argValue != null && !paramType.isInstance(argValue)) { + return false; + } + } + return true; + } + + private static boolean primitiveIsCompatible(Class<?> primitive, Object value) { + if (value == null || !Primitives.isWrapperType(value.getClass())) { + return false; + } + return primitiveTypeIsAssignmentCompatible(primitive, Primitives.unwrap(value.getClass())); + } + + private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.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. + */ + Object invokeMethod(Method method, Object target, List<Object> argValues) { + try { + return method.invoke(target, argValues.toArray()); + } catch (InvocationTargetException e) { + throw evaluationException(e.getCause()); + } catch (Exception e) { + throw evaluationException(e); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/Reparser.java b/src/main/java/com/google/escapevelocity/Reparser.java new file mode 100644 index 0000000..7f87e89 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Reparser.java @@ -0,0 +1,283 @@ +/* + * 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 com.google.escapevelocity.Node.emptyNode; + +import com.google.escapevelocity.DirectiveNode.ForEachNode; +import com.google.escapevelocity.DirectiveNode.IfNode; +import com.google.escapevelocity.DirectiveNode.MacroCallNode; +import com.google.escapevelocity.DirectiveNode.SetNode; +import com.google.escapevelocity.TokenNode.CommentTokenNode; +import com.google.escapevelocity.TokenNode.ElseIfTokenNode; +import com.google.escapevelocity.TokenNode.ElseTokenNode; +import com.google.escapevelocity.TokenNode.EndTokenNode; +import com.google.escapevelocity.TokenNode.EofNode; +import com.google.escapevelocity.TokenNode.ForEachTokenNode; +import com.google.escapevelocity.TokenNode.IfOrElseIfTokenNode; +import com.google.escapevelocity.TokenNode.IfTokenNode; +import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode; +import com.google.escapevelocity.TokenNode.NestedTokenNode; +import com.google.common.base.CharMatcher; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * The second phase of parsing. See {@link Parser#parse()} for a description of the phases and why + * we need them. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Reparser { + private static final ImmutableSet<Class<? extends TokenNode>> END_SET = + ImmutableSet.<Class<? extends TokenNode>>of(EndTokenNode.class); + private static final ImmutableSet<Class<? extends TokenNode>> EOF_SET = + ImmutableSet.<Class<? extends TokenNode>>of(EofNode.class); + private static final ImmutableSet<Class<? extends TokenNode>> ELSE_ELSE_IF_END_SET = + ImmutableSet.<Class<? extends TokenNode>>of( + ElseTokenNode.class, ElseIfTokenNode.class, EndTokenNode.class); + + /** + * The nodes that make up the input sequence. Nodes are removed one by one from this list as + * parsing proceeds. At any time, {@link #currentNode} is the node being examined. + */ + private final ImmutableList<Node> nodes; + + /** + * The index of the node we are currently looking at while parsing. + */ + private int nodeIndex; + + /** + * Macros are removed from the input as they are found. They do not appear in the output parse + * tree. Macro definitions are not executed in place but are all applied before template rendering + * starts. This means that a macro can be referenced before it is defined. + */ + private final Map<String, Macro> macros; + + Reparser(ImmutableList<Node> nodes) { + this(nodes, new TreeMap<>()); + } + + private Reparser(ImmutableList<Node> nodes, Map<String, Macro> macros) { + this.nodes = removeSpaceBeforeSet(nodes); + this.nodeIndex = 0; + this.macros = macros; + } + + Template reparse() { + Node root = reparseNodes(); + linkMacroCalls(); + return new Template(root); + } + + private Node reparseNodes() { + return parseTo(EOF_SET, new EofNode((String) null, 1)); + } + + /** + * Returns a copy of the given list where spaces have been moved where appropriate after {@code + * #set}. This hack is needed to match what appears to be special treatment in Apache Velocity of + * spaces before {@code #set} directives. If you have <i>thing</i> <i>whitespace</i> {@code #set}, + * then the whitespace is deleted if the <i>thing</i> is a comment ({@code ##...\n}); a reference + * ({@code $x} or {@code $x.foo} etc); a macro definition; or another {@code #set}. + */ + private static ImmutableList<Node> removeSpaceBeforeSet(ImmutableList<Node> nodes) { + assert Iterables.getLast(nodes) instanceof EofNode; + // Since the last node is EofNode, the i + 1 and i + 2 accesses below are safe. + ImmutableList.Builder<Node> newNodes = ImmutableList.builder(); + for (int i = 0; i < nodes.size(); i++) { + Node nodeI = nodes.get(i); + newNodes.add(nodeI); + if (shouldDeleteSpaceBetweenThisAndSet(nodeI) + && isWhitespaceLiteral(nodes.get(i + 1)) + && nodes.get(i + 2) instanceof SetNode) { + // Skip the space. + i++; + } + } + return newNodes.build(); + } + + private static boolean shouldDeleteSpaceBetweenThisAndSet(Node node) { + return node instanceof CommentTokenNode + || node instanceof ReferenceNode + || node instanceof SetNode + || node instanceof MacroDefinitionTokenNode; + } + + private static boolean isWhitespaceLiteral(Node node) { + if (node instanceof ConstantExpressionNode) { + Object constant = node.evaluate(null); + return constant instanceof String && CharMatcher.whitespace().matchesAllOf((String) constant); + } + return false; + } + + /** + * Parse subtrees until one of the token types in {@code stopSet} is encountered. + * If this is the top level, {@code stopSet} will include {@link EofNode} so parsing will stop + * when it reaches the end of the input. Otherwise, if an {@code EofNode} is encountered it is an + * error because we have something like {@code #if} without {@code #end}. + * + * @param stopSet the kinds of tokens that will stop the parse. For example, if we are parsing + * after an {@code #if}, we will stop at any of {@code #else}, {@code #elseif}, + * or {@code #end}. + * @param forWhat the token that triggered this call, for example the {@code #if} whose + * {@code #end} etc we are looking for. + * + * @return a Node that is the concatenation of the parsed subtrees + */ + private Node parseTo(Set<Class<? extends TokenNode>> stopSet, TokenNode forWhat) { + ImmutableList.Builder<Node> nodeList = ImmutableList.builder(); + while (true) { + Node currentNode = currentNode(); + if (stopSet.contains(currentNode.getClass())) { + break; + } + if (currentNode instanceof EofNode) { + throw new ParseException( + "Reached end of file while parsing " + forWhat.name(), + forWhat.resourceName, + forWhat.lineNumber); + } + Node parsed; + if (currentNode instanceof TokenNode) { + parsed = parseTokenNode(); + } else { + parsed = currentNode; + nextNode(); + } + nodeList.add(parsed); + } + return Node.cons(forWhat.resourceName, forWhat.lineNumber, nodeList.build()); + } + + private Node currentNode() { + return nodes.get(nodeIndex); + } + + private Node nextNode() { + Node currentNode = currentNode(); + if (currentNode instanceof EofNode) { + return currentNode; + } else { + nodeIndex++; + return currentNode(); + } + } + + private Node parseTokenNode() { + TokenNode tokenNode = (TokenNode) currentNode(); + nextNode(); + if (tokenNode instanceof CommentTokenNode) { + return emptyNode(tokenNode.resourceName, tokenNode.lineNumber); + } else if (tokenNode instanceof IfTokenNode) { + return parseIfOrElseIf((IfTokenNode) tokenNode); + } else if (tokenNode instanceof ForEachTokenNode) { + return parseForEach((ForEachTokenNode) tokenNode); + } else if (tokenNode instanceof NestedTokenNode) { + return parseNested((NestedTokenNode) tokenNode); + } else if (tokenNode instanceof MacroDefinitionTokenNode) { + return parseMacroDefinition((MacroDefinitionTokenNode) tokenNode); + } else { + throw new IllegalArgumentException( + "Unexpected token: " + tokenNode.name() + " on line " + tokenNode.lineNumber); + } + } + + private Node parseForEach(ForEachTokenNode forEach) { + Node body = parseTo(END_SET, forEach); + nextNode(); // Skip #end + return new ForEachNode( + forEach.resourceName, forEach.lineNumber, forEach.var, forEach.collection, body); + } + + private Node parseIfOrElseIf(IfOrElseIfTokenNode ifOrElseIf) { + Node truePart = parseTo(ELSE_ELSE_IF_END_SET, ifOrElseIf); + Node falsePart; + Node token = currentNode(); + nextNode(); // Skip #else or #elseif (cond) or #end. + if (token instanceof EndTokenNode) { + falsePart = emptyNode(token.resourceName, token.lineNumber); + } else if (token instanceof ElseTokenNode) { + falsePart = parseTo(END_SET, ifOrElseIf); + nextNode(); // Skip #end + } else if (token instanceof ElseIfTokenNode) { + // We've seen #if (condition1) ... #elseif (condition2). currentToken is the first token + // after (condition2). We pretend that we've just seen #if (condition2) and parse out + // the remainder (which might have further #elseif and final #else). Then we pretend that + // we actually saw #if (condition1) ... #else #if (condition2) ...remainder ... #end #end. + falsePart = parseIfOrElseIf((ElseIfTokenNode) token); + } else { + throw new AssertionError(currentNode()); + } + return new IfNode( + ifOrElseIf.resourceName, ifOrElseIf.lineNumber, ifOrElseIf.condition, truePart, falsePart); + } + + // This is a #parse("foo.vm") directive. We've already done the first phase of parsing on the + // contents of foo.vm. Now we need to do the second phase, and insert the result into the + // reparsed nodes. We can call Reparser recursively, but we must ensure that any macros found + // are added to the containing Reparser's macro definitions. + private Node parseNested(NestedTokenNode nested) { + Reparser reparser = new Reparser(nested.nodes, this.macros); + return reparser.reparseNodes(); + } + + private Node parseMacroDefinition(MacroDefinitionTokenNode macroDefinition) { + Node body = parseTo(END_SET, macroDefinition); + nextNode(); // Skip #end + if (!macros.containsKey(macroDefinition.name)) { + Macro macro = new Macro( + macroDefinition.lineNumber, macroDefinition.name, macroDefinition.parameterNames, body); + macros.put(macroDefinition.name, macro); + } + return emptyNode(macroDefinition.resourceName, macroDefinition.lineNumber); + } + + private void linkMacroCalls() { + for (Node node : nodes) { + if (node instanceof MacroCallNode) { + linkMacroCall((MacroCallNode) node); + } + } + } + + private void linkMacroCall(MacroCallNode macroCall) { + Macro macro = macros.get(macroCall.name()); + if (macro == null) { + throw new ParseException( + "#" + macroCall.name() + + " is neither a standard directive nor a macro that has been defined", + macroCall.resourceName, + macroCall.lineNumber); + } + if (macro.parameterCount() != macroCall.argumentCount()) { + throw new ParseException( + "Wrong number of arguments to #" + macroCall.name() + + ": expected " + macro.parameterCount() + + ", got " + macroCall.argumentCount(), + macroCall.resourceName, + macroCall.lineNumber); + } + macroCall.setMacro(macro); + } +} diff --git a/src/main/java/com/google/escapevelocity/Template.java b/src/main/java/com/google/escapevelocity/Template.java new file mode 100644 index 0000000..6bc75c2 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Template.java @@ -0,0 +1,133 @@ +/* + * 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 com.google.escapevelocity.EvaluationContext.PlainEvaluationContext; +import java.io.IOException; +import java.io.Reader; +import java.util.Map; + +/** + * A template expressed in EscapeVelocity, a subset of the Velocity Template Language (VTL) from + * Apache. The intent of this implementation is that if a template is accepted and successfully + * produces output, that output will be identical to what Velocity would have produced for the same + * template and input variables. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +// TODO(emcmanus): spell out exactly what Velocity features are unsupported. +public class Template { + private final Node root; + + /** + * Caches {@link Method} objects for public methods accessed through references. The first time + * we evaluate {@code $var.property} or {@code $var.method(...)} for a {@code $var} of a given + * class and for a given property or method signature, we'll store the resultant {@link Method} + * object. Every subsequent time we'll reuse that {@link Method}. The method lookup is quite slow + * so caching is useful. The main downside is that we may potentially hold on to {@link Method} + * objects that will never be used with this {@link Template} again. But in practice templates + * tend to be used repeatedly with the same classes. + */ + private final MethodFinder methodFinder = new MethodFinder(); + + /** + * Used to resolve references to resources in the template, through {@code #parse} directives. + * + * <p>Here is an example that opens nested templates as resources relative to the calling class: + * + * <pre>{@code + * ResourceOpener resourceOpener = resourceName -> { + * InputStream inputStream = getClass().getResource(resourceName); + * if (inputStream == null) { + * throw new IOException("Unknown resource: " + resourceName); + * } + * return new BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8)); + * }; + * }</pre> + */ + @FunctionalInterface + public interface ResourceOpener { + + /** + * Returns a {@code Reader} that will be used to read the given resource, then closed. + * + * @param resourceName the name of the resource to be read. This will never be null. + * @return a {@code Reader} for the resource. + * @throws IOException if the resource cannot be opened. + */ + Reader openResource(String resourceName) throws IOException; + } + + /** + * Parses a VTL template from the given {@code Reader}. The template cannot reference other + * templates (for example with {@code #parse}). For that, use + * {@link #parseFrom(String, ResourceOpener)}. + * + * @param reader a Reader that will supply the text of the template. It will be closed on return + * from this method. + * @return an object representing the parsed template. + * @throws IOException if there is an exception reading from {@code reader}, or if the template + * references another template via {@code #parse}. + */ + public static Template parseFrom(Reader reader) throws IOException { + ResourceOpener resourceOpener = resourceName -> { + if (resourceName == null) { + return reader; + } else { + throw new IOException("No ResourceOpener has been configured to read " + resourceName); + } + }; + try { + return parseFrom((String) null, resourceOpener); + } finally { + reader.close(); + } + } + + /** + * Parse a VTL template of the given name using the given {@code ResourceOpener}. + * + * @param resourceName name of the resource. May be null. + * @param resourceOpener used to open the initial resource and resources referenced by + * {@code #parse} directives in the template. + * @return an object representing the parsed template. + * @throws IOException if there is an exception opening or reading from any resource. + */ + public static Template parseFrom( + String resourceName, ResourceOpener resourceOpener) throws IOException { + try (Reader reader = resourceOpener.openResource(resourceName)) { + return new Parser(reader, resourceName, resourceOpener).parse(); + } + } + + Template(Node root) { + this.root = root; + } + + /** + * Evaluate the given template with the given initial set of variables. + * + * @param vars a map where the keys are variable names and the values are the corresponding + * variable values. For example, if {@code "x"} maps to 23, then {@code $x} in the template + * will expand to 23. + * + * @return the string result of evaluating the template. + */ + public String evaluate(Map<String, ?> vars) { + EvaluationContext evaluationContext = new PlainEvaluationContext(vars, methodFinder); + return String.valueOf(root.evaluate(evaluationContext)); + } +} diff --git a/src/main/java/com/google/escapevelocity/TokenNode.java b/src/main/java/com/google/escapevelocity/TokenNode.java new file mode 100644 index 0000000..971ad30 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/TokenNode.java @@ -0,0 +1,169 @@ +/* + * 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 com.google.common.collect.ImmutableList; +import java.util.List; + +/** + * A parsing node that will be deleted during the construction of the parse tree, to be replaced + * by a higher-level construct such as {@link DirectiveNode.IfNode}. See {@link Parser#parse()} + * for a description of the way these tokens work. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class TokenNode extends Node { + TokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * This method always throws an exception because a node like this should never be found in the + * final parse tree. + */ + @Override Object evaluate(EvaluationContext vars) { + throw new UnsupportedOperationException(getClass().getName()); + } + + /** + * The name of the token, for use in parse error messages. + */ + abstract String name(); + + /** + * A synthetic node that represents the end of the input. This node is the last one in the + * initial token string and also the last one in the parse tree. + */ + static final class EofNode extends TokenNode { + EofNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override + String name() { + return "end of file"; + } + } + + static final class EndTokenNode extends TokenNode { + EndTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#end"; + } + } + + /** + * A node in the parse tree representing a comment. Comments are introduced by {@code ##} and + * extend to the end of the line. The only reason for recording comment nodes is so that we can + * skip space between a comment and a following {@code #set}, to be compatible with Velocity + * behaviour. + */ + static class CommentTokenNode extends TokenNode { + CommentTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "##"; + } + } + + abstract static class IfOrElseIfTokenNode extends TokenNode { + final ExpressionNode condition; + + IfOrElseIfTokenNode(ExpressionNode condition) { + super(condition.resourceName, condition.lineNumber); + this.condition = condition; + } + } + + static final class IfTokenNode extends IfOrElseIfTokenNode { + IfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#if"; + } + } + + static final class ElseIfTokenNode extends IfOrElseIfTokenNode { + ElseIfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#elseif"; + } + } + + static final class ElseTokenNode extends TokenNode { + ElseTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#else"; + } + } + + static final class ForEachTokenNode extends TokenNode { + final String var; + final ExpressionNode collection; + + ForEachTokenNode(String var, ExpressionNode collection) { + super(collection.resourceName, collection.lineNumber); + this.var = var; + this.collection = collection; + } + + @Override String name() { + return "#foreach"; + } + } + + static final class NestedTokenNode extends TokenNode { + final ImmutableList<Node> nodes; + + NestedTokenNode(String resourceName, ImmutableList<Node> nodes) { + super(resourceName, 1); + this.nodes = nodes; + } + + @Override String name() { + return "#parse(\"" + resourceName + "\")"; + } + } + + static final class MacroDefinitionTokenNode extends TokenNode { + final String name; + final ImmutableList<String> parameterNames; + + MacroDefinitionTokenNode( + String resourceName, int lineNumber, String name, List<String> parameterNames) { + super(resourceName, lineNumber); + this.name = name; + this.parameterNames = ImmutableList.copyOf(parameterNames); + } + + @Override String name() { + return "#macro(" + name + ")"; + } + } +} diff --git a/src/test/java/com/google/escapevelocity/MethodFinderTest.java b/src/test/java/com/google/escapevelocity/MethodFinderTest.java new file mode 100644 index 0000000..66b8948 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/MethodFinderTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2019 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 com.google.common.truth.Truth.assertThat; +import static java.util.stream.Collectors.toSet; + +import com.google.common.collect.ImmutableMap; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MethodFinderTest { + @Test + public void visibleMethodFromClass() throws Exception { + Map<String, String> map = Collections.singletonMap("foo", "bar"); + Class<?> mapClass = map.getClass(); + assertThat(Modifier.isPublic(mapClass.getModifiers())).isFalse(); + + Method size = mapClass.getMethod("size"); + Method visibleSize = MethodFinder.visibleMethod(size, mapClass); + assertThat(visibleSize.getDeclaringClass().isInterface()).isFalse(); + assertThat(visibleSize.invoke(map)).isEqualTo(1); + } + + @Test + public void visibleMethodFromInterface() throws Exception { + Map<String, String> map = ImmutableMap.of("foo", "bar"); + Map.Entry<String, String> entry = map.entrySet().iterator().next(); + Class<?> entryClass = entry.getClass(); + assertThat(Modifier.isPublic(entryClass.getModifiers())).isFalse(); + + Method getValue = entryClass.getMethod("getValue"); + Method visibleGetValue = MethodFinder.visibleMethod(getValue, entryClass); + assertThat(visibleGetValue.getDeclaringClass().isInterface()).isTrue(); + assertThat(visibleGetValue.invoke(entry)).isEqualTo("bar"); + } + + @Test + public void publicMethodsWithName() { + List<String> list = Collections.singletonList("foo"); + Class<?> listClass = list.getClass(); + assertThat(Modifier.isPublic(listClass.getModifiers())).isFalse(); + + MethodFinder methodFinder = new MethodFinder(); + Set<Method> methods = methodFinder.publicMethodsWithName(listClass, "remove"); + // This should find at least remove(int) and remove(Object). + assertThat(methods.size()).isAtLeast(2); + assertThat(methods.stream().map(Method::getName).collect(toSet())).containsExactly("remove"); + assertThat(methods.stream().allMatch(MethodFinderTest::isPublic)).isTrue(); + + // We should cache the result, meaning we get back the same result if we ask a second time. + Set<Method> methods2 = methodFinder.publicMethodsWithName(listClass, "remove"); + assertThat(methods2).isSameInstanceAs(methods); + } + + @Test + public void publicMethodsWithName_Nonexistent() { + List<String> list = Collections.singletonList("foo"); + Class<?> listClass = list.getClass(); + assertThat(Modifier.isPublic(listClass.getModifiers())).isFalse(); + + MethodFinder methodFinder = new MethodFinder(); + Set<Method> methods = methodFinder.publicMethodsWithName(listClass, "nonexistentMethod"); + assertThat(methods).isEmpty(); + } + + private static boolean isPublic(Method method) { + return Modifier.isPublic(method.getModifiers()) + && Modifier.isPublic(method.getDeclaringClass().getModifiers()); + } +} diff --git a/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java new file mode 100644 index 0000000..b1759bd --- /dev/null +++ b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java @@ -0,0 +1,94 @@ +/* + * 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 com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Primitives; +import com.google.common.truth.Expect; +import com.google.escapevelocity.ReferenceNode.MethodReferenceNode; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for {@link ReferenceNode}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class ReferenceNodeTest { + @Rule public Expect expect = Expect.create(); + + // This is the exhaustive list from + // https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2. + // We put the "from" type first for consistency with that list, even though that is inconsistent + // with our method order (which is itself consistent with assignment, "to" on the left). + private static final ImmutableSet<ImmutableList<Class<?>>> ASSIGNMENT_COMPATIBLE = + makeAssignmentCompatibleSet(); + private static ImmutableSet<ImmutableList<Class<?>>> makeAssignmentCompatibleSet() { + Class<?>[][] pairs = { + {byte.class, short.class}, + {byte.class, int.class}, + {byte.class, long.class}, + {byte.class, float.class}, + {byte.class, double.class}, + {short.class, int.class}, + {short.class, long.class}, + {short.class, float.class}, + {short.class, double.class}, + {char.class, int.class}, + {char.class, long.class}, + {char.class, float.class}, + {char.class, double.class}, + {int.class, long.class}, + {int.class, float.class}, + {int.class, double.class}, + {long.class, float.class}, + {long.class, double.class}, + {float.class, double.class}, + }; + ImmutableSet.Builder<ImmutableList<Class<?>>> builder = ImmutableSet.builder(); + for (Class<?>[] pair : pairs) { + builder.add(ImmutableList.copyOf(pair)); + } + return builder.build(); + } + + @Test + public void testPrimitiveTypeIsAssignmentCompatible() { + for (Class<?> from : Primitives.allPrimitiveTypes()) { + for (Class<?> to : Primitives.allPrimitiveTypes()) { + boolean expected = + (from == to || ASSIGNMENT_COMPATIBLE.contains(ImmutableList.of(from, to))); + boolean actual = + MethodReferenceNode.primitiveTypeIsAssignmentCompatible(to, from); + expect + .withMessage(from + " assignable to " + to) + .that(actual).isEqualTo(expected); + } + } + } + + @Test + public void testCompatibleArgs() { + assertThat(MethodReferenceNode.compatibleArgs( + new Class<?>[]{int.class}, ImmutableList.of((Object) 5))).isTrue(); + } +} diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java new file mode 100644 index 0000000..0503125 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/TemplateTest.java @@ -0,0 +1,954 @@ +/* + * 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 com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.fail; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.truth.Expect; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Supplier; +import org.apache.commons.collections.ExtendedProperties; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.exception.ResourceNotFoundException; +import org.apache.velocity.exception.VelocityException; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.RuntimeInstance; +import org.apache.velocity.runtime.log.NullLogChute; +import org.apache.velocity.runtime.parser.node.SimpleNode; +import org.apache.velocity.runtime.resource.Resource; +import org.apache.velocity.runtime.resource.loader.ResourceLoader; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class TemplateTest { + @Rule public TestName testName = new TestName(); + @Rule public Expect expect = Expect.create(); + + private RuntimeInstance velocityRuntimeInstance; + + @Before + public void initVelocityRuntimeInstance() { + velocityRuntimeInstance = newVelocityRuntimeInstance(); + velocityRuntimeInstance.init(); + } + + private RuntimeInstance newVelocityRuntimeInstance() { + RuntimeInstance runtimeInstance = new RuntimeInstance(); + + // Ensure that $undefinedvar will produce an exception rather than outputting $undefinedvar. + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true"); + + // Disable any logging that Velocity might otherwise see fit to do. + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, new NullLogChute()); + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute()); + return runtimeInstance; + } + + private void compare(String template) { + compare(template, ImmutableMap.<String, Object>of()); + } + + private void compare(String template, Map<String, ?> vars) { + compare(template, () -> vars); + } + + /** + * Checks that the given template and the given variables produce identical results with + * Velocity and EscapeVelocity. This uses a {@code Supplier} to define the variables to cover + * test cases that involve modifying the values of the variables. Otherwise the run using + * Velocity would change those values so that the run using EscapeVelocity would not be starting + * from the same point. + */ + private void compare(String template, Supplier<? extends Map<String, ?>> varsSupplier) { + Map<String, ?> velocityVars = varsSupplier.get(); + String velocityRendered = velocityRender(template, velocityVars); + Map<String, ?> escapeVelocityVars = varsSupplier.get(); + String escapeVelocityRendered; + try { + escapeVelocityRendered = + Template.parseFrom(new StringReader(template)).evaluate(escapeVelocityVars); + } catch (Exception e) { + throw new AssertionError( + "EscapeVelocity failed, but Velocity succeeded and returned: <" + velocityRendered + ">", + e); + } + String failure = "from Velocity: <" + velocityRendered + ">\n" + + "from EscapeVelocity: <" + escapeVelocityRendered + ">\n"; + expect.withMessage(failure).that(escapeVelocityRendered).isEqualTo(velocityRendered); + } + + private String velocityRender(String template, Map<String, ?> vars) { + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + StringWriter writer = new StringWriter(); + SimpleNode parsedTemplate; + try { + parsedTemplate = velocityRuntimeInstance.parse( + new StringReader(template), testName.getMethodName()); + } catch (org.apache.velocity.runtime.parser.ParseException e) { + throw new AssertionError(e); + } + boolean rendered = velocityRuntimeInstance.render( + velocityContext, writer, parsedTemplate.getTemplateName(), parsedTemplate); + assertThat(rendered).isTrue(); + return writer.toString(); + } + + private void expectParseException( + String template, + String expectedMessageSubstring) { + Exception velocityException = null; + try { + SimpleNode parsedTemplate = + velocityRuntimeInstance.parse(new StringReader(template), testName.getMethodName()); + VelocityContext velocityContext = new VelocityContext(new TreeMap<>()); + velocityRuntimeInstance.render( + velocityContext, new StringWriter(), parsedTemplate.getTemplateName(), parsedTemplate); + fail("Velocity did not throw an exception for this template"); + } catch (org.apache.velocity.runtime.parser.ParseException | VelocityException expected) { + velocityException = expected; + } + try { + Template.parseFrom(new StringReader(template)); + fail("Velocity generated an exception, but EscapeVelocity did not: " + velocityException); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (ParseException expected) { + assertWithMessage("Got expected exception, but message did not match") + .that(expected).hasMessageThat().contains(expectedMessageSubstring); + } + } + + @Test + public void empty() { + compare(""); + } + + @Test + public void literalOnly() { + compare("In the reign of James the Second \n It was generally reckoned\n"); + } + + @Test + public void lineComment() { + compare("line 1 ##\n line 2"); + } + + @Test + public void blockComment() { + compare("line 1 #* blah\n line 2 * #\n line 3 *# \n line 4"); + compare("foo #*# bar *# baz"); + compare("foo #* one *# #* two *# #* three *#"); + compare("foo #** bar *# #* baz **#"); + } + + @Test + public void ignoreHashIfNotDirectiveOrComment() { + compare("# if is not a directive because of the space"); + compare("#<foo>"); + compare("# <foo>"); + compare("${foo}#${bar}", ImmutableMap.of("foo", "xxx", "bar", "yyy")); + } + + @Test + public void blockQuote() { + compare("#[[]]#"); + compare("x#[[]]#y"); + compare("#[[$notAReference #notADirective]]#"); + compare("#[[ [[ ]] ]# ]]#"); + compare("#[ foo"); + compare("x\n #[[foo\nbar\nbaz]]#y"); + } + + @Test + public void substituteNoBraces() { + compare(" $x ", ImmutableMap.of("x", 1729)); + compare(" ! $x ! ", ImmutableMap.of("x", 1729)); + } + + @Test + public void dollarWithoutId() { + compare(" $? "); + compare(" $$ "); + compare(" $. "); + compare(" $[ "); + } + + @Test + public void doubleDollar() { + // The first $ is plain text and the second one starts a reference. + compare(" $$foo ", ImmutableMap.of("foo", true)); + compare(" $${foo} ", ImmutableMap.of("foo", true)); + } + + @Test + public void substituteWithBraces() { + compare("a${x}\nb", ImmutableMap.of("x", "1729")); + } + + @Test + public void substitutePropertyNoBraces() { + compare("=$t.name=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substitutePropertyWithBraces() { + compare("=${t.name}=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteNotPropertyId() { + compare("$foo.!", ImmutableMap.of("foo", false)); + } + + @Test + public void substituteNestedProperty() { + compare("\n$t.name.empty\n", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteMethodNoArgs() { + compare("<$c.size()>", ImmutableMap.of("c", ImmutableMap.of())); + } + + @Test + public void substituteMethodNoArgsSyntheticOverride() { + compare("<$c.isEmpty()>", ImmutableMap.of("c", ImmutableSetMultimap.of())); + } + + @Test + public void substituteMethodOneArg() { + compare("<$list.get(0)>", ImmutableMap.of("list", ImmutableList.of("foo"))); + } + + @Test + public void substituteMethodOneNullArg() { + // This should evaluate map.containsKey(map.get("absent")), which is map.containsKey(null). + compare("<$map.containsKey($map.get(\"absent\"))>", ImmutableMap.of("map", ImmutableMap.of())); + } + + @Test + public void substituteMethodTwoArgs() { + compare("\n$s.indexOf(\"bar\", 2)\n", ImmutableMap.of("s", "barbarbar")); + } + + @Test + public void substituteMethodSyntheticOverloads() { + // If we aren't careful, we'll see both the inherited `Set<K> keySet()` from Map + // and the overridden `ImmutableSet<K> keySet()` in ImmutableMap. + compare("$map.keySet()", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + } + + @Test + public void substituteStaticMethod() { + compare("$Integer.toHexString(23)", ImmutableMap.of("Integer", Integer.class)); + } + + @Test + public void substituteStaticMethodAsInstanceMethod() { + compare("$i.toHexString(23)", ImmutableMap.of("i", 0)); + } + + @Test + public void substituteClassMethod() { + // This is Class.getName(). + compare("$Integer.getName()", ImmutableMap.of("Integer", Integer.class)); + } + + /** See {@link #substituteClassOrInstanceMethod}. */ + public static class GetName { + public static String getName() { + return "Noddy"; + } + } + + @Test + public void substituteClassOrInstanceMethod() { + // If the method exists as both an instance method on Class and a static method on the named + // class, it's the instance method that wins, so this is still Class.getName(). + compare("$GetName.getName()", ImmutableMap.of("GetName", GetName.class)); + } + + @Test + public void substituteIndexNoBraces() { + compare("<$map[\"x\"]>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexWithBraces() { + compare("<${map[\"x\"]}>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + // Velocity allows you to write $map.foo instead of $map["foo"]. + @Test + public void substituteMapProperty() { + compare("$map.foo", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + // $map.empty is always equivalent to $map["empty"], never Map.isEmpty(). + compare("$map.empty", ImmutableMap.of("map", ImmutableMap.of("empty", "foo"))); + } + + @Test + public void substituteIndexThenProperty() { + compare("<$map[2].name>", ImmutableMap.of("map", ImmutableMap.of(2, getClass()))); + } + + @Test + public void variableNameCantStartWithNonAscii() { + compare("<$Éamonn>", ImmutableMap.<String, Object>of()); + } + + @Test + public void variableNamesAreAscii() { + compare("<$Pádraig>", ImmutableMap.of("P", "(P)")); + } + + @Test + public void variableNameCharacters() { + compare("<AZaz-foo_bar23>", ImmutableMap.of("AZaz-foo_bar23", "(P)")); + } + + /** + * A public class with a public {@code get} method that has one argument. That means instances can + * be used like {@code $indexable["foo"]}. + */ + public static class Indexable { + public String get(String y) { + return "[" + y + "]"; + } + } + + @Test + public void substituteExoticIndex() { + // Any class with a get(X) method can be used with $x[i] + compare("<$x[\"foo\"]>", ImmutableMap.of("x", new Indexable())); + } + + @Test + public void substituteInString() { + String template = + "#foreach ($a in $list)" + + "#set ($s = \"THING_${foreach.index}\")" + + "$s,$s;" + + "#end"; + compare(template, ImmutableMap.of("list", ImmutableList.of(1, 2, 3))); + compare("#set ($s = \"$x\") <$s>", ImmutableMap.of("x", "fred")); + compare("#set ($s = \"==$x$y\") <$s>", ImmutableMap.of("x", "fred", "y", "jim")); + compare("#set ($s = \"$x$y==\") <$s>", ImmutableMap.of("x", "fred", "y", "jim")); + } + + @Test + public void stringOperationsOnSubstitution() { + compare("#set ($s = \"a${b}c\") $s.length()", ImmutableMap.of("b", 23)); + } + + @Test + public void singleQuoteNoSubstitution() { + compare("#set ($s = 'a${b}c') x${s}y", ImmutableMap.of("b", 23)); + } + + @Test + public void simpleSet() { + compare("$x#set ($x = 17)#set ($y = 23) ($x, $y)", ImmutableMap.of("x", 1)); + } + + @Test + public void newlineAfterSet() { + compare("foo #set ($x = 17)\nbar", ImmutableMap.<String, Object>of()); + } + + @Test + public void newlineInSet() { + compare("foo #set ($x\n = 17)\nbar $x", ImmutableMap.<String, Object>of()); + } + + @Test + public void expressions() { + compare("#set ($x = 1 + 1) $x"); + compare("#set ($x = 1 + 2 * 3) $x"); + compare("#set ($x = (1 + 1 == 2)) $x"); + compare("#set ($x = (1 + 1 != 2)) $x"); + compare("#set ($x = 22 - 7) $x"); + compare("#set ($x = 22 / 7) $x"); + compare("#set ($x = 22 % 7) $x"); + } + + @Test + public void associativity() { + compare("#set ($x = 3 - 2 - 1) $x"); + compare("#set ($x = 16 / 4 / 4) $x"); + } + + @Test + public void precedence() { + compare("#set ($x = 1 + 2 + 3 * 4 * 5 + 6) $x"); + compare("#set($x=1+2+3*4*5+6)$x"); + compare("#set ($x = 1 + 2 * 3 == 3 * 2 + 1) $x"); + } + + @Test + public void and() { + compare("#set ($x = false && false) $x"); + compare("#set ($x = false && true) $x"); + compare("#set ($x = true && false) $x"); + compare("#set ($x = true && true) $x"); + } + + @Test + public void or() { + compare("#set ($x = false || false) $x"); + compare("#set ($x = false || true) $x"); + compare("#set ($x = true || false) $x"); + compare("#set ($x = true || true) $x"); + } + + @Test + public void not() { + compare("#set ($x = !true) $x"); + compare("#set ($x = !false) $x"); + } + + @Test + public void truthValues() { + compare("#set ($x = $true && true) $x", ImmutableMap.of("true", true)); + compare("#set ($x = $false && true) $x", ImmutableMap.of("false", false)); + compare("#set ($x = $emptyCollection && true) $x", + ImmutableMap.of("emptyCollection", ImmutableList.of())); + compare("#set ($x = $emptyString && true) $x", ImmutableMap.of("emptyString", "")); + } + + @Test + public void numbers() { + compare("#set ($x = 0) $x"); + compare("#set ($x = -1) $x"); + compare("#set ($x = " + Integer.MAX_VALUE + ") $x"); + compare("#set ($x = " + Integer.MIN_VALUE + ") $x"); + } + + private static final String[] RELATIONS = {"==", "!=", "<", ">", "<=", ">="}; + + @Test + public void intRelations() { + int[] numbers = {-1, 0, 1, 17}; + for (String relation : RELATIONS) { + for (int a : numbers) { + for (int b : numbers) { + compare("#set ($x = $a " + relation + " $b) $x", + ImmutableMap.<String, Object>of("a", a, "b", b)); + } + } + } + } + + @Test + public void relationPrecedence() { + compare("#set ($x = 1 < 2 == 2 < 1) $x"); + compare("#set ($x = 2 < 1 == 2 < 1) $x"); + } + + /** + * Tests the surprising definition of equality mentioned in + * {@link ExpressionNode.BinaryExpressionNode}. + */ + @Test + public void funkyEquals() { + compare("#set ($t = (123 == \"123\")) $t"); + compare("#set ($f = (123 == \"1234\")) $f"); + compare("#set ($x = ($sb1 == $sb2)) $x", ImmutableMap.of( + "sb1", (Object) new StringBuilder("123"), + "sb2", (Object) new StringBuilder("123"))); + } + + @Test + public void ifTrueNoElse() { + compare("x#if (true)y #end z"); + compare("x#if (true)y #end z"); + compare("x#if (true)y #end\nz"); + compare("x#if (true)y #end\n z"); + compare("x#if (true) y #end\nz"); + compare("x#if (true)\ny #end\nz"); + compare("x#if (true) y #end\nz"); + compare("$x #if (true) y #end $x ", ImmutableMap.of("x", "!")); + } + + @Test + public void ifFalseNoElse() { + compare("x#if (false)y #end z"); + compare("x#if (false)y #end\nz"); + compare("x#if (false)y #end\n z"); + compare("x#if (false) y #end\nz"); + compare("x#if (false)\ny #end\nz"); + compare("x#if (false) y #end\nz"); + } + + @Test + public void ifTrueWithElse() { + compare("x#if (true) a #else b #end z"); + } + + @Test + public void ifFalseWithElse() { + compare("x#if (false) a #else b #end z"); + } + + @Test + public void ifTrueWithElseIf() { + compare("x#if (true) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfTrue() { + compare("x#if (false) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfFalse() { + compare("x#if (false) a #elseif (false) b #else c #end z"); + } + + @Test + public void ifBraces() { + compare("x#{if}(false)a#{elseif}(false)b #{else}c#{end}z"); + } + @Test + public void ifUndefined() { + compare("#if ($undefined) really? #else indeed #end"); + } + + @Test + public void forEach() { + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", new String[] {"foo", "bar", "baz"})); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableMap.of("foo", "bar", "baz", "buh"))); + } + + @Test + public void forEachHasNext() { + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + } + + @Test + public void nestedForEach() { + String template = + "$x #foreach ($x in $listOfLists)\n" + + " #foreach ($y in $x)\n" + + " ($y)#if ($foreach.hasNext), #end\n" + + " #end#if ($foreach.hasNext); #end\n" + + "#end\n" + + "$x\n"; + Object listOfLists = ImmutableList.of( + ImmutableList.of("foo", "bar", "baz"), ImmutableList.of("fred", "jim", "sheila")); + compare(template, ImmutableMap.of("x", 23, "listOfLists", listOfLists)); + } + + @Test + public void forEachScope() { + String template = + "$x #foreach ($x in $list)\n" + + "[$x]\n" + + "#set ($x = \"bar\")\n" + + "#set ($othervar = \"baz\")\n" + + "#end\n" + + "$x $othervar"; + compare( + template, ImmutableMap.of("x", "foo", "list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void forEachIndex() { + String template = + "#foreach ($x in $list)" + + "[$foreach.index]" + + "#foreach ($y in $list)" + + "($foreach.index)==$x.$y==" + + "#end" + + "#end"; + compare(template, ImmutableMap.of("list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void setSpacing() { + // The spacing in the output from #set is eccentric. + compare("x#set ($x = 0)x"); + compare("x #set ($x = 0)x"); + compare("x #set ($x = 0) x"); + compare("$x#set ($x = 0)x", ImmutableMap.of("x", "!")); + + // Velocity WTF: the #set eats the space after $x and other references, so the output is <!x>. + compare("$x #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.length() #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.empty #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x[0] #set ($x = 0)x", ImmutableMap.of("x", ImmutableList.of("!"))); + + compare("x#set ($x = 0)\n $x!"); + + compare("x #set($x = 0) #set($x = 0) #set($x = 0) y"); + + compare("x ## comment\n #set($x = 0) y"); + compare("x #* comment *# #set($x = 0) y"); + } + + @Test + public void simpleMacro() { + String template = + "xyz\n" + + "#macro (m)\n" + + "hello world\n" + + "#end\n" + + "#m() abc #m()\n"; + compare(template); + } + + @Test + public void macroWithArgs() { + String template = + "$x\n" + + "#macro (m $x $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + @Test + public void macroWithCommaSeparatedArgs() { + String template = + "$x\n" + + "#macro (m, $x, $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + /** + * Tests defining a macro inside a conditional. This proves that macros are not evaluated in the + * main control flow, but rather are extracted at parse time. It also tests what happens if there + * is more than one definition of the same macro. (It is not apparent from the test, but it is the + * first definition that is retained.) + */ + @Test + public void conditionalMacroDefinition() { + String templateFalse = + "#if (false)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateFalse); + + String templateTrue = + "#if (true)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateTrue); + } + + /** + * Tests referencing a macro before it is defined. Since macros are extracted at parse time but + * references are only used at evaluation time, this works. + */ + @Test + public void forwardMacroReference() { + String template = + "#m(17)\n" + + "#macro (m $x)\n" + + " !$x!\n" + + "#end"; + compare(template); + } + + @Test + public void macroArgsSeparatedBySpaces() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0] $list.get(1) 5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + @Test + public void macroArgsSeparatedByCommas() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0],$list.get(1),5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + // The following tests are based on http://wiki.apache.org/velocity/MacroEvaluationStrategy. + // They verify some of the trickier details of Velocity's call-by-name semantics. + + @Test + public void callBySharing() { + // The example on the web page is wrong because $map.put('x', 'a') evaluates to null, which + // Velocity rejects as a render error. We fix this by ensuring that the returned previous value + // is not null. + // Here, the value of $y should not be affected by #set($x = "a"), even though the name passed + // to $x is $y. + String template = + "#macro(callBySharing $x $map)\n" + + " #set($x = \"a\")\n" + + " $map.put(\"x\", \"a\")\n" + + "#end\n" + + "#callBySharing($y $map)\n" + + "y is $y\n" + + "map[x] is $map[\"x\"]\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of( + "y", "y", + "map", new HashMap<String, Object>(ImmutableMap.of("x", (Object) "foo"))); + } + }; + compare(template, makeMap); + } + + @Test + public void callByMacro() { + // Since #callByMacro1 never references its argument, $x.add("t") is never evaluated during it. + // Since #callByMacro2 references its argument twice, $x.add("t") is evaluated twice during it. + String template = + "#macro(callByMacro1 $p)\n" + + " not using\n" + + "#end\n" + + "#macro(callByMacro2 $p)\n" + + " using: $p\n" + + " using again: $p\n" + + " using again: $p\n" + + "#end\n" + + "#callByMacro1($x.add(\"t\"))\n" + + "x = $x\n" + + "#callByMacro2($x.add(\"t\"))\n" + + "x = $x\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of("x", new ArrayList<Object>()); + } + }; + compare(template, makeMap); + } + + @Test + public void callByValue() { + // The assignments to the macro parameters $a and $b cause those parameters to be shadowed, + // so the output is: a b becomes b a. + String template = + "#macro(callByValueSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end" + + "#callByValueSwap(\"a\", \"b\")"; + compare(template); + } + + // First "Call by macro expansion example" doesn't apply as long as we don't have map literals. + + @Test + public void nameCaptureSwap() { + // Here, the arguments $a and $b are variables rather than literals, which means that their + // values change when we set those variables. #set($tmp = $a) changes the meaning of $b since + // $b is the name $tmp. So #set($a = $b) shadows parameter $a with the value of $tmp, which we + // have just set to "a". Then #set($b = $tmp) shadows parameter $b also with the value of $tmp. + // The end result is: a b becomes a a. + String template = + "#macro(nameCaptureSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end\n" + + "#set($x = \"a\")\n" + + "#set($tmp = \"b\")\n" + + "#nameCaptureSwap($x $tmp)"; + compare(template); + } + + @Test + public void badBraceReference() { + String template = "line 1\nline 2\nbar${foo.!}baz"; + expectParseException(template, "Expected }, on line 3, at text starting: .!}baz"); + } + + @Test + public void undefinedMacro() { + String template = "#oops()"; + expectParseException( + template, + "#oops is neither a standard directive nor a macro that has been defined"); + } + + @Test + public void macroArgumentMismatch() { + String template = + "#macro (twoArgs $a $b) $a $b #end\n" + + "#twoArgs(23)\n"; + expectParseException(template, "Wrong number of arguments to #twoArgs: expected 2, got 1"); + } + + @Test + public void unclosedBlockQuote() { + String template = "foo\nbar #[[\nblah\nblah"; + expectParseException(template, "Unterminated #[[ - did not see matching ]]#, on line 2"); + } + + @Test + public void unclosedBlockComment() { + compare("foo\nbar #*\nblah\nblah"); + } + + /** + * A Velocity ResourceLoader that looks resources up in a map. This allows us to test directives + * that read "resources", for example {@code #parse}, without needing to make separate files to + * put them in. + */ + private static final class MapResourceLoader extends ResourceLoader { + private final ImmutableMap<String, String> resourceMap; + + MapResourceLoader(ImmutableMap<String, String> resourceMap) { + this.resourceMap = resourceMap; + } + + @Override + public void init(ExtendedProperties configuration) { + } + + @Override + public InputStream getResourceStream(String source) { + String resource = resourceMap.get(source); + if (resource == null) { + throw new ResourceNotFoundException(source); + } + return new ByteArrayInputStream(resource.getBytes(StandardCharsets.ISO_8859_1)); + } + + @Override + public boolean isSourceModified(Resource resource) { + return false; + } + + @Override + public long getLastModified(Resource resource) { + return 0; + } + }; + + private String renderWithResources( + String templateResourceName, + ImmutableMap<String, String> resourceMap, + ImmutableMap<String, String> vars) { + MapResourceLoader mapResourceLoader = new MapResourceLoader(resourceMap); + RuntimeInstance runtimeInstance = newVelocityRuntimeInstance(); + runtimeInstance.setProperty("resource.loader", "map"); + runtimeInstance.setProperty("map.resource.loader.instance", mapResourceLoader); + runtimeInstance.init(); + org.apache.velocity.Template velocityTemplate = + runtimeInstance.getTemplate(templateResourceName); + StringWriter velocityWriter = new StringWriter(); + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + velocityTemplate.merge(velocityContext, velocityWriter); + return velocityWriter.toString(); + } + + @Test + public void parseDirective() throws IOException { + // If outer.vm does #parse("nested.vm"), then we should be able to #set a variable in + // nested.vm and use it in outer.vm, and we should be able to define a #macro in nested.vm + // and call it in outer.vm. + ImmutableMap<String, String> resources = ImmutableMap.of( + "outer.vm", + "first line\n" + + "#parse (\"nested.vm\")\n" + + "<#decorate (\"left\" \"right\")>\n" + + "$baz skidoo\n" + + "last line\n", + "nested.vm", + "nested template first line\n" + + "[#if ($foo == $bar) equal #else not equal #end]\n" + + "#macro (decorate $a $b) < $a | $b > #end\n" + + "#set ($baz = 23)\n" + + "nested template last line\n"); + + ImmutableMap<String, String> vars = ImmutableMap.of("foo", "foovalue", "bar", "barvalue"); + + String velocityResult = renderWithResources("outer.vm", resources, vars); + + Template.ResourceOpener resourceOpener = resourceName -> { + String resource = resources.get(resourceName); + if (resource == null) { + throw new FileNotFoundException(resourceName); + } + return new StringReader(resource); + }; + Template template = Template.parseFrom("outer.vm", resourceOpener); + + String result = template.evaluate(vars); + assertThat(result).isEqualTo(velocityResult); + + ImmutableMap<String, String> badVars = ImmutableMap.of("foo", "foovalue"); + try { + template.evaluate(badVars); + fail(); + } catch (EvaluationException e) { + assertThat(e).hasMessageThat().isEqualTo( + "In expression on line 2 of nested.vm: Undefined reference $bar"); + } + } +} |