aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com')
-rw-r--r--src/main/java/com/google/escapevelocity/ConstantExpressionNode.java43
-rw-r--r--src/main/java/com/google/escapevelocity/DirectiveNode.java226
-rw-r--r--src/main/java/com/google/escapevelocity/EvaluationContext.java85
-rw-r--r--src/main/java/com/google/escapevelocity/EvaluationException.java34
-rw-r--r--src/main/java/com/google/escapevelocity/ExpressionNode.java188
-rw-r--r--src/main/java/com/google/escapevelocity/Macro.java141
-rw-r--r--src/main/java/com/google/escapevelocity/MethodFinder.java172
-rw-r--r--src/main/java/com/google/escapevelocity/Node.java92
-rw-r--r--src/main/java/com/google/escapevelocity/ParseException.java41
-rw-r--r--src/main/java/com/google/escapevelocity/Parser.java1094
-rw-r--r--src/main/java/com/google/escapevelocity/ReferenceNode.java332
-rw-r--r--src/main/java/com/google/escapevelocity/Reparser.java283
-rw-r--r--src/main/java/com/google/escapevelocity/Template.java133
-rw-r--r--src/main/java/com/google/escapevelocity/TokenNode.java169
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 + ")";
+ }
+ }
+}