diff options
Diffstat (limited to 'src/main/java/com')
14 files changed, 3033 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 + ")"; + } + } +} |