diff options
author | Éamonn McManus <eamonn@mcmanus.net> | 2017-12-27 16:54:45 -0800 |
---|---|---|
committer | Éamonn McManus <eamonn@mcmanus.net> | 2017-12-27 16:54:45 -0800 |
commit | d68715b3e62b4f1b8ebd7fa051004934a2ba913d (patch) | |
tree | 160d1cdf81b4b61c3e27d74fff3e6f4a74ed675a /src | |
download | escapevelocity-d68715b3e62b4f1b8ebd7fa051004934a2ba913d.tar.gz |
Initial version.
This is forked from the code built in to AutoValue, with the following non-trivial changes:
(1) Package changed from com.google.auto.value.processor.escapevelocity to com.google.escapevelocity.
(2) New pom.xml.
(3) Code rewritten to remove Guava dependency, so no shading or diamond dependency problems.
Diffstat (limited to 'src')
20 files changed, 4190 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..a4dfe17 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 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..cf33f55 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/DirectiveNode.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2015 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 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(); + Iterator<?> it = 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(); + } + + /** + * 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)}. + */ + private static class ForEachVar { + private final Iterator<?> iterator; + + ForEachVar(Iterator<?> iterator) { + this.iterator = iterator; + } + + public boolean getHasNext() { + return iterator.hasNext(); + } + } + } + + /** + * 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) { + assert macro != null : "Macro 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..43b7868 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationContext.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 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 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); + + class PlainEvaluationContext implements EvaluationContext { + private final Map<String, Object> vars; + + PlainEvaluationContext(Map<String, ?> vars) { + this.vars = new TreeMap<String, Object>(vars); + } + + @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 = new Runnable() { + @Override public void run() { + vars.put(var, oldValue); + } + }; + } else { + undo = new Runnable() { + @Override public void run() { + vars.remove(var); + } + }; + } + vars.put(var, value); + return undo; + } + } +} 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..67aa15c --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015 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(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..4ee29c5 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ExpressionNode.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2015 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/ImmutableAsciiSet.java b/src/main/java/com/google/escapevelocity/ImmutableAsciiSet.java new file mode 100644 index 0000000..96a126c --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableAsciiSet.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2017 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 java.util.AbstractSet; +import java.util.BitSet; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * An immutable set of ASCII characters. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableAsciiSet extends AbstractSet<Integer> { + private final BitSet bits; + + ImmutableAsciiSet(BitSet bits) { + this.bits = bits; + } + + static ImmutableAsciiSet of(char c) { + return ofRange(c, c); + } + + static ImmutableAsciiSet ofRange(char from, char to) { + if (from > to) { + throw new IllegalArgumentException("from > to"); + } + if (to >= 128) { + throw new IllegalArgumentException("Not ASCII"); + } + BitSet bits = new BitSet(); + bits.set(from, to + 1); + return new ImmutableAsciiSet(bits); + } + + ImmutableAsciiSet union(ImmutableAsciiSet that) { + BitSet union = (BitSet) bits.clone(); + union.or(that.bits); + return new ImmutableAsciiSet(union); + } + + @Override + public boolean contains(Object o) { + int i = -1; + if (o instanceof Character) { + i = (Character) o; + } else if (o instanceof Integer) { + i = (Integer) o; + } + return contains(i); + } + + boolean contains(int i) { + if (i < 0) { + return false; + } else { + return bits.get(i); + } + } + + @Override + public Iterator<Integer> iterator() { + return new Iterator<Integer>() { + private int index; + + @Override + public boolean hasNext() { + return bits.nextSetBit(index) >= 0; + } + + @Override + public Integer next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + int next = bits.nextSetBit(index); + index = next + 1; + return next; + } + }; + } + + @Override + public int size() { + return bits.cardinality(); + } +} diff --git a/src/main/java/com/google/escapevelocity/ImmutableList.java b/src/main/java/com/google/escapevelocity/ImmutableList.java new file mode 100644 index 0000000..0b903f7 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableList.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2017 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 java.util.AbstractList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * An immutable list. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableList<E> extends AbstractList<E> { + private static final ImmutableList<?> EMPTY = new ImmutableList<>(new Object[0]); + + private final E[] elements; + + private ImmutableList(E[] elements) { + this.elements = elements; + } + + @Override + public Iterator<E> iterator() { + return Arrays.asList(elements).iterator(); + } + + @Override + public E get(int index) { + if (index < 0 || index >= elements.length) { + throw new IndexOutOfBoundsException(String.valueOf(index)); + } + return elements[index]; + } + + @Override + public int size() { + return elements.length; + } + + static <E> ImmutableList<E> of() { + @SuppressWarnings("unchecked") + ImmutableList<E> empty = (ImmutableList<E>) EMPTY; + return empty; + } + + @SafeVarargs + static <E> ImmutableList<E> of(E... elements) { + return new ImmutableList<>(elements.clone()); + } + + static <E> ImmutableList<E> copyOf(List<E> list) { + @SuppressWarnings("unchecked") + E[] elements = (E[]) new Object[list.size()]; + list.toArray(elements); + return new ImmutableList<>(elements); + } + + static <E> Builder<E> builder() { + return new Builder<E>(); + } + + static class Builder<E> { + private final List<E> list = new ArrayList<>(); + + void add(E element) { + list.add(element); + } + + ImmutableList<E> build() { + if (list.isEmpty()) { + return ImmutableList.of(); + } + @SuppressWarnings("unchecked") + E[] elements = (E[]) new Object[list.size()]; + list.toArray(elements); + return new ImmutableList<>(elements); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/ImmutableSet.java b/src/main/java/com/google/escapevelocity/ImmutableSet.java new file mode 100644 index 0000000..f4e8e9f --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableSet.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2017 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 java.util.AbstractSet; +import java.util.Arrays; +import java.util.Iterator; + +/** + * An immutable set. This implementation is only suitable for sets with a small number of elements. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableSet<E> extends AbstractSet<E> { + private final E[] elements; + + private ImmutableSet(E[] elements) { + this.elements = elements; + } + + @Override + public Iterator<E> iterator() { + return Arrays.asList(elements).iterator(); + } + + @Override + public int size() { + return elements.length; + } + + @SafeVarargs + static <E> ImmutableSet<E> of(E... elements) { + int len = elements.length; + for (int i = 0; i < len - 1; i++) { + for (int j = len - 1; j > i; j--) { + if (elements[i].equals(elements[j])) { + // We want to exclude elements[j] from the final set. We can do that by copying the + // current last element in place of j (this might be j itself) and then reducing the + // size of the set. + elements[j] = elements[len - 1]; + len--; + } + } + } + return new ImmutableSet<>(Arrays.copyOf(elements, len)); + } +} 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..151ded2 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Macro.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2015 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 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 { + assert thunks.size() == parameterNames.size() : "Argument mistmatch for " + 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 new Runnable() { + @Override + public void run() { + originalUndo.run(); + parameterThunks.put(var, thunk); + } + }; + } + } + } +} 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..eca745f --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Node.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2015 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. + * + * @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..241a192 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ParseException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 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..9982be3 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Parser.java @@ -0,0 +1,963 @@ +/* + * Copyright (C) 2015 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.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; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 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 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. + */ + private int c; + + 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) { + c = reader.read(); + } + } + + /** + * 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(); + if (c == '#') { + return parseComment(); + } else if (isAsciiLetter(c) || c == '{') { + return parseDirective(); + } else if (c == '[') { + return parseHashSquare(); + } else { + // For consistency with Velocity, we treat # not followed by # or a letter as a plain + // character, and we treat #$foo as a literal # followed by the reference $foo. + // But the # is its own ConstantExpressionNode; we don't try to merge it with adjacent text. + return new ConstantExpressionNode(resourceName, lineNumber(), "#"); + } + } + 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 new ConstantExpressionNode(resourceName, lineNumber(), "#["); + } + next(); + StringBuilder sb = new StringBuilder(); + while (true) { + if (c == EOF) { + throw parseException("Unterminated #[[ - did not see matching ]]#"); + } + 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 != '"') { + throw parseException("#parse only supported with string literal argument"); + } + String nestedResourceName = readStringLiteral(); + 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 not separated by commas, though method-reference parameters are. + */ + 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 != '$') { + 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 comment, which is {@code ##} followed by any number of characters up to + * and including the next newline. + */ + private Node parseComment() throws IOException { + int lineNumber = lineNumber(); + while (c != '\n' && c != EOF) { + next(); + } + next(); + return new CommentTokenNode(resourceName, lineNumber); + } + + /** + * 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); + + 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 ReferenceNode parseReference() 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(); + 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 Map<Integer, List<Operator>> CODE_POINT_TO_OPERATORS; + static { + Map<Integer, List<Operator>> map = new HashMap<>(); + for (Operator operator : Operator.values()) { + if (operator != Operator.STOP) { + Integer key = operator.symbol.codePointAt(0); + if (!map.containsKey(key)) { + map.put(key, new ArrayList<Operator>()); + } + map.get(key).add(operator); + } + } + CODE_POINT_TO_OPERATORS = Collections.unmodifiableMap(map); + } + + /** + * 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(); + List<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c); + if (possibleOperators == null) { + currentOperator = Operator.STOP; + return; + } + int firstChar = c; + next(); + Operator operator = null; + for (Operator possibleOperator : possibleOperators) { + if (possibleOperator.symbol.length() == 1) { + assert operator == null; + operator = possibleOperator; + } else if (possibleOperator.symbol.charAt(1) == c) { + next(); + operator = possibleOperator; + } + } + if (operator == null) { + throw parseException("Expected " + possibleOperators.get(0) + ", 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 = parseReference(); + } else if (c == '"') { + node = parseStringLiteral(); + } 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; + } + + private ExpressionNode parseStringLiteral() throws IOException { + return new ConstantExpressionNode(resourceName, lineNumber(), readStringLiteral()); + } + + private String readStringLiteral() throws IOException { + assert c == '"'; + StringBuilder sb = new StringBuilder(); + next(); + while (c != '"') { + if (c == '\n' || c == EOF) { + throw parseException("Unterminated string constant"); + } + if (c == '$' || c == '\\') { + // In real Velocity, you can have a $ reference expanded inside a "" string literal. + // There are also '' string literals where that is not so. We haven't needed that yet + // so it's not supported. + throw parseException( + "Escapes or references in string constants are not currently supported"); + } + sb.appendCodePoint(c); + next(); + } + next(); + return sb.toString(); + } + + private ExpressionNode parseIntLiteral(String prefix) throws IOException { + StringBuilder sb = new StringBuilder(prefix); + while (isAsciiDigit(c)) { + sb.appendCodePoint(c); + next(); + } + int value; + try { + value = Integer.parseInt(sb.toString()); + } catch (NumberFormatException e) { + 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 ImmutableAsciiSet ASCII_LETTER = + ImmutableAsciiSet.ofRange('A', 'Z') + .union(ImmutableAsciiSet.ofRange('a', 'z')); + + private static final ImmutableAsciiSet ASCII_DIGIT = + ImmutableAsciiSet.ofRange('0', '9'); + + private static final ImmutableAsciiSet ID_CHAR = + ASCII_LETTER + .union(ASCII_DIGIT) + .union(ImmutableAsciiSet.of('-')) + .union(ImmutableAsciiSet.of('_')); + + private static boolean isAsciiLetter(int c) { + return ASCII_LETTER.contains(c); + } + + private static boolean isAsciiDigit(int c) { + return ASCII_DIGIT.contains(c); + } + + private static boolean isIdChar(int c) { + return ID_CHAR.contains(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/README.md b/src/main/java/com/google/escapevelocity/README.md new file mode 100644 index 0000000..0e9ff1e --- /dev/null +++ b/src/main/java/com/google/escapevelocity/README.md @@ -0,0 +1,378 @@ +# EscapeVelocity summary + +EscapeVelocity is a templating engine that can be used from Java. It is a reimplementation of a subset of +functionality from [Apache Velocity](http://velocity.apache.org/). + +This is not a supported Google product. + +For a fuller explanation of Velocity's functioning, see its +[User Guide](http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html) + +If EscapeVelocity successfully produces a result from a template evaluation, that result should be +the exact same string that Velocity produces. If not, that is a bug. + +EscapeVelocity has no facilities for HTML escaping and it is not appropriate for producing +HTML output that might include portions of untrusted input. + +## Motivation + +Velocity has a convenient templating language. It is easy to read, and it has widespread support +from tools such as editors and coding websites. However, *using* Velocity can prove difficult. +Its use to generate Java code in the [AutoValue][AutoValue] annotation processor required many +[workarounds][VelocityHacks]. The way it dynamically loads classes as part of its standard operation +makes it hard to [shade](https://maven.apache.org/plugins/maven-shade-plugin/) it, which in the case +of AutoValue led to interference if Velocity was used elsewhere in a project. + +EscapeVelocity has a simple API that does not involve any class-loading or other sources of +problems. It and its dependencies can be shaded with no difficulty. + +## Loading a template + +The entry point for EscapeVelocity is the `Template` class. To obtain an instance, use +`Template.from(Reader)`. If a template is stored in a file, that file conventionally has the +suffix `.vm` (for Velocity Macros). But since the argument is a `Reader`, you can also load +a template directly from a Java string, using `StringReader`. + +Here's how you might make a `Template` instance from a template file that is packaged as a resource +in the same package as the calling class: + +```java +InputStream in = getClass().getResourceAsStream("foo.vm"); +if (in == null) { + throw new IllegalArgumentException("Could not find resource foo.vm"); +} +Reader reader = new BufferedReader(new InputStreamReader(in)); +Template template = Template.parseFrom(reader); +``` + +## Expanding a template + +Once you have a `Template` object, you can use it to produce a string where the variables in the +template are given the values you provide. You can do this any number of times, specifying the +same or different values each time. + +Suppose you have this template: + +``` +The $language word for $original is $translated. +``` + +You might write this code: + +```java +Map<String, String> vars = new HashMap<>(); +vars.put("language", "French"); +vars.put("original", "toe"); +vars.put("translated", "orteil"); +String result = template.evaluate(vars); +``` + +The `result` string would then be: `The French word for toe is orteil.` + +## Comments + +The characters `##` introduce a comment. Characters from `##` up to and including the following +newline are omitted from the template. This template has comments: + +``` +Line 1 ## with a comment +Line 2 +``` + +It is the same as this template: +``` +Line 1 Line 2 +``` + +## References + +EscapeVelocity supports most of the reference types described in the +[Velocity User Guide](http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html#References) + +### Variables + +A variable has an ASCII name that starts with a letter (a-z or A-Z) and where any other characters +are also letters or digits or hyphens (-) or underscores (_). A variable reference can be written +as `$foo` or as `${foo}`. The value of a variable can be of any Java type. If the value `v` of +variable `foo` is not a String then the result of `$foo` in a template will be `String.valueOf(v)`. +Variables must be defined before they are referenced; otherwise an `EvaluationException` will be +thrown. + +Variable names are case-sensitive: `$foo` is not the same variable as `$Foo` or `$FOO`. + +Initially the values of variables come from the Map that is passed to `Template.evaluate`. Those +values can be changed, and new ones defined, using the `#set` directive in the template: + +``` +#set ($foo = "bar") +``` + +Setting a variable affects later references to it in the template, but has no effect on the +`Map` that was passed in or on later template evaluations. + +### Properties + +If a reference looks like `$purchase.Total` then the value of the `$purchase` variable must be a +Java object that has a public method `getTotal()` or `gettotal()`, or a method called `isTotal()` or +`istotal()` that returns `boolean`. The result of `$purchase.Total` is then the result of calling +that method on the `$purchase` object. + +If you want to have a period (`.`) after a variable reference *without* it being a property +reference, you can use braces like this: `${purchase}.Total`. If, after a property reference, you +have a further period, you can put braces around the reference like this: +`${purchase.Total}.nonProperty`. + +### Methods + +If a reference looks like `$purchase.addItem("scones", 23)` then the value of the `$purchase` +variable must be a Java object that has a public method `addItem` with two parameters that match +the given values. Unlike Velocity, EscapeVelocity requires that there be exactly one such method. +It is OK if there are other `addItem` methods provided they are not compatible with the +arguments provided. + +Properties are in fact a special case of methods: instead of writing `$purchase.Total` you could +write `$purchase.getTotal()`. Braces can be used to make the method invocation explicit +(`${purchase.getTotal()}`) or to prevent method invocation (`${purchase}.getTotal()`). + +### Indexing + +If a reference looks like `$indexme[$i]` then the value of the `$indexme` variable must be a Java +object that has a public `get` method that takes one argument that is compatible with the index. +For example, `$indexme` might be a `List` and `$i` might be an integer. Then the reference would +be the result of `List.get(int)` for that list and that integer. Or, `$indexme` might be a `Map`, +and the reference would be the result of `Map.get(Object)` for the object `$i`. In general, +`$indexme[$i]` is equivalent to `$indexme.get($i)`. + +Unlike Velocity, EscapeVelocity does not allow `$indexme` to be a Java array. + +### Undefined references + +If a variable has not been given a value, either by being in the initial Map argument or by being +set in the template, then referencing it will provoke an `EvaluationException`. There is +a special case for `#if`: if you write `#if ($var)` then it is allowed for `$var` not to be defined, +and it is treated as false. + +### Setting properties and indexes: not supported + +Unlke Velocity, EscapeVelocity does not allow `#set` assignments with properties or indexes: + +``` +#set ($data.User = "jon") ## Allowed in Velocity but not in EscapeVelocity +#set ($map["apple"] = "orange") ## Allowed in Velocity but not in EscapeVelocity +``` + +## Expressions + +In certain contexts, such as the `#set` directive we have just seen or certain other directives, +EscapeVelocity can evaluate expressions. An expression can be any of these: + +* A reference, of the kind we have just seen. The value is the value of the reference. +* A string literal enclosed in double quotes, like `"this"`. A string literal must appear on + one line. EscapeVelocity does not support the characters `$` or `\\` in a string literal. +* An integer literal such as `23` or `-100`. EscapeVelocity does not support floating-point + literals. +* A Boolean literal, `true` or `false`. +* Simpler expressions joined together with operators that have the same meaning as in Java: + `!`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `+`, `-`, `*`, `/`, `%`. The operators have the + same precedence as in Java. +* A simpler expression in parentheses, for example `(2 + 3)`. + +Velocity supports string literals with single quotes, like `'this`' and also references within +strings, like `"a $reference in a string"`, but EscapeVelocity does not. + +## Directives + +A directive is introduced by a `#` character followed by a word. We have already seen the `#set` +directive, which sets the value of a variable. The other directives are listed below. + +Directives can be spelled with or without braces, so `#set` or `#{set}`. + +### `#if`/`#elseif`/`#else` + +The `#if` directive selects parts of the template according as a condition is true or false. +The simplest case looks like this: + +``` +#if ($condition) yes #end +``` + +This evaluates to the string ` yes ` if the variable `$condition` is defined and has a true value, +and to the empty string otherwise. It is allowed for `$condition` not to be defined in this case, +and then it is treated as false. + +The expression in `#if` (here `$condition`) is considered true if its value is not null and not +equal to the Boolean value `false`. + +An `#if` directive can also have an `#else` part, for example: + +``` +#if ($condition) yes #else no #end +``` + +This evaluates to the string ` yes ` if the condition is true or the string ` no ` if it is not. + +An `#if` directive can have any number of `#elseif` parts. For example: + +``` +#if ($i == 0) zero #elseif ($i == 1) one #elseif ($i == 2) two #else many #end +``` + +### `#foreach` + +The `#foreach` directive repeats a part of the template once for each value in a list. + +``` +#foreach ($product in $allProducts) + ${product}! +#end +``` + +This will produce one line for each value in the `$allProducts` variable. The value of +`$allProducts` can be a Java `Iterable`, such as a `List` or `Set`; or it can be an object array; +or it can be a Java `Map`. When it is a `Map` the `#foreach` directive loops over every *value* +in the `Map`. + +If `$allProducts` is a `List` containing the strings `oranges` and `lemons` then the result of the +`#foreach` would be this: + +``` + + oranges! + + + lemons! + +``` + +When the `#foreach` completes, the loop variable (`$product` in the example) goes back to whatever +value it had before, or to being undefined if it was undefined before. + +Within the `#foreach`, a special variable `$foreach` is defined, such that you can write +`$foreach.hasNext`, which will be true if there are more values after this one or false if this +is the last value. For example: + +``` +#foreach ($product in $allProducts)${product}#if ($foreach.hasNext), #end#end +``` + +This would produce the output `oranges, lemons` for the list above. (The example is scrunched up +to avoid introducing extraneous spaces, as described in the [section](#spaces) on spaces +below.) + +Velocity gives the `$foreach` variable other properties (`index` and `count`) but EscapeVelocity +does not. + +### Macros + +A macro is a part of the template that can be reused in more than one place, potentially with +different parameters each time. In the simplest case, a macro has no arguments: + +``` +#macro (hello) bonjour #end +``` + +Then the macro can be referenced by writing `#hello()` and the result will be the string ` bonjour ` +inserted at that point. + +Macros can also have parameters: + +``` +#macro (greet $hello $world) $hello, $world! #end +``` + +Then `#greet("bonjour", "monde")` would produce ` bonjour, monde! `. The comma is optional, so +you could also write `#greet("bonjour" "monde")`. + +When a macro completes, the parameters (`$hello` and `$world` in the example) go back to whatever +values they had before, or to being undefined if they were undefined before. + +All macro definitions take effect before the template is evaluated, so you can use a macro at a +point in the template that is before the point where it is defined. This also means that you can't +define a macro conditionally: + +``` +## This doesn't work! +#if ($language == "French") +#macro (hello) bonjour #end +#else +#macro (hello) hello #end +#end +``` + +There is no particular reason to define the same macro more than once, but if you do it is the +first definition that is retained. In the `#if` example just above, the `bonjour` version will +always be used. + +Macros can make templates hard to understand. You may prefer to put the logic in a Java method +rather than a macro, and call the method from the template using `$methods.doSomething("foo")` +or whatever. + +## Block quoting + +If you have text that should be treated verbatim, you can enclose it in `#[[...]]#`. The text +represented by `...` will be copied into the output. `#` and `$` characters will have no +effect in that text. + +``` +#[[ This is not a #directive, and this is not a $variable. ]]# +``` + +## Including other templates + +If you want to include a template from another file, you can use the `#parse` directive. +This can be useful if you have macros that are shared between templates, for example. + +``` +#set ($foo = "bar") +#parse("macros.vm") +#mymacro($foo) ## #mymacro defined in macros.vm +``` + +For this to work, you will need to tell EscapeVelocity how to find "resources" such as +`macro.vm` in the example. You might use something like this: + +``` +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)); +}; +Template template = Template.parseFrom("foo.vm", resourceOpener); +``` + +In this case, the `resourceOpener` is used to find the main template `foo.vm`, as well as any +templates it may reference in `#parse` directives. + +## <a name="spaces"></a> Spaces + +For the most part, spaces and newlines in the template are preserved exactly in the output. +To avoid unwanted newlines, you may end up using `##` comments. In the `#foreach` example above +we had this: + +``` +#foreach ($product in $allProducts)${product}#if ($foreach.hasNext), #end#end +``` + +That was to avoid introducing unwanted spaces and newlines. A more readable way to achieve the same +result is this: + +``` +#foreach ($product in $allProducts)## +${product}## +#if ($foreach.hasNext), #end## +#end +``` + +Spaces are ignored between the `#` of a directive and the `)` that closes it, so there is no trace +in the output of the spaces in `#foreach ($product in $allProducts)` or `#if ($foreach.hasNext)`. +Spaces are also ignored inside references, such as `$indexme[ $i ]` or `$callme( $i , $j )`. + +If you are concerned about the detailed formatting of the text from the template, you may want to +post-process it. For example, if it is Java code, you could use a formatter such as +[google-java-format](https://github.com/google/google-java-format). Then you shouldn't have to +worry about extraneous spaces. + +[VelocityHacks]: https://github.com/google/auto/blob/ca2384d5ad15a0c761b940384083cf5c50c6e839/value/src/main/java/com/google/auto/value/processor/TemplateVars.java#L54 +[AutoValue]: https://github.com/google/auto/tree/master/value 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..865d02a --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2015 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 java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 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"); + } + // 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; + Method method; + try { + method = lhsValue.getClass().getMethod(methodName); + 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()); + } + } catch (NoSuchMethodException e) { + // Continue with next possibility + } + } + } + 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"); + } + List<Object> argValues = new ArrayList<>(); + for (ExpressionNode arg : args) { + argValues.add(arg.evaluate(context)); + } + List<Method> methodsWithName = new ArrayList<>(); + for (Method method : lhsValue.getClass().getMethods()) { + if (method.getName().equals(id) && !method.isSynthetic()) { + methodsWithName.add(method); + } + } + if (methodsWithName.isEmpty()) { + throw evaluationException("No method " + id + " in " + lhsValue.getClass().getName()); + } + List<Method> compatibleMethods = new ArrayList<>(); + for (Method method : methodsWithName) { + // TODO(emcmanus): support varargs, if it's useful + if (compatibleArgs(method.getParameterTypes(), argValues)) { + compatibleMethods.add(method); + } + } + switch (compatibleMethods.size()) { + case 0: + throw evaluationException( + "Parameters for method " + id + " have wrong types: " + argValues); + case 1: + return invokeMethod(compatibleMethods.get(0), lhsValue, argValues); + default: + StringBuilder error = new StringBuilder("Ambiguous method invocation, could be one of:"); + for (Method method : compatibleMethods) { + error.append("\n ").append(method); + } + throw evaluationException(error.toString()); + } + } + + /** + * 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 (!paramType.isInstance(argValue)) { + return false; + } + } + return true; + } + + private static final Map<Class<?>, Class<?>> BOXED_TO_UNBOXED; + static { + Map<Class<?>, Class<?>> map = new HashMap<>(); + map.put(Byte.class, byte.class); + map.put(Short.class, short.class); + map.put(Integer.class, int.class); + map.put(Long.class, long.class); + map.put(Float.class, float.class); + map.put(Double.class, double.class); + map.put(Character.class, char.class); + map.put(Boolean.class, boolean.class); + BOXED_TO_UNBOXED = Collections.unmodifiableMap(map); + } + + private static boolean primitiveIsCompatible(Class<?> primitive, Object value) { + if (value == null) { + return false; + } + Class<?> unboxed = BOXED_TO_UNBOXED.get(value.getClass()); + if (unboxed == null) { + return false; + } + return primitiveTypeIsAssignmentCompatible(primitive, unboxed); + } + + private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.<Class<?>>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. The method is expected + * to be public, but the class it is in might not be. In that case we will search up the + * hierarchy for an ancestor that is public and has the same method, and use that to invoke the + * method. Otherwise we would get an {@link IllegalAccessException}. More than one ancestor might + * define the method, but it doesn't matter which one we invoke since ultimately the code that + * will run will be the same. + */ + Object invokeMethod(Method method, Object target, List<Object> argValues) { + if (!classIsPublic(target.getClass())) { + method = visibleMethod(method, target.getClass()); + if (method == null) { + throw evaluationException( + "Method is not visible in class " + target.getClass().getName() + ": " + method); + } + } + try { + return method.invoke(target, argValues.toArray()); + } catch (InvocationTargetException e) { + throw evaluationException(e.getCause()); + } catch (Exception e) { + throw evaluationException(e); + } + } + + private static String packageNameOf(Class<?> c) { + String name = c.getName(); + int lastDot = name.lastIndexOf('.'); + if (lastDot > 0) { + return name.substring(0, lastDot); + } else { + return ""; + } + } + + private static final String THIS_PACKAGE = packageNameOf(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 methods of $foreach without having + // to make the ForEachVar class public. We can invoke those methods from here since they + // are in the same package. + return methodInClass; + } + Method methodSuper = visibleMethod(method, in.getSuperclass()); + if (methodSuper != null) { + return methodSuper; + } + for (Class<?> intf : in.getInterfaces()) { + Method methodIntf = visibleMethod(method, intf); + if (methodIntf != null) { + return methodIntf; + } + } + 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) { + if (!Modifier.isPublic(c.getModifiers())) { + return false; + } + if (CLASS_GET_MODULE_METHOD != null) { + return classIsExported(c); + } + return true; + } + + private static boolean classIsExported(Class<?> c) { + try { + String pkg = packageNameOf(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/Reparser.java b/src/main/java/com/google/escapevelocity/Reparser.java new file mode 100644 index 0000000..6235bc4 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Reparser.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2015 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 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<String, Macro>()); + } + + 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 nodes.get(nodes.size() - 1) instanceof EofNode : nodes.get(nodes.size() - 1); + // 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); + if (constant instanceof String) { + String s = (String) constant; + int i = 0; + while (i < s.length()) { + int c = s.codePointAt(i); + if (!Character.isWhitespace(c)) { + return false; + } + i += Character.charCount(c); + } + return true; + } + } + 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..646c42b --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Template.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 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; + + /** + * 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> + * 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 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. + */ + Reader openResource(String resourceName) throws IOException; + } + + /** + * Parses a VTL template from the given {@code Reader}. The given Reader will be closed on + * return from this method. + */ + public static Template parseFrom(final Reader reader) throws IOException { + ResourceOpener resourceOpener = new ResourceOpener() { + @Override + public Reader openResource(String resourceName) throws IOException { + 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 included files for {@code #parse} directives in the + * template. + */ + 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); + 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..1e92109 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/TokenNode.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2015 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 java.util.List; + +/** + * A parsing node that will be deleted during the construction of the parse tree, to be replaced + * by a higher-level construct such as {@link DirectiveNode.IfNode}. See {@link Parser#parse()} + * for a description of the way these tokens work. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class TokenNode extends Node { + TokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * This method always throws an exception because a node like this should never be found in the + * final parse tree. + */ + @Override Object evaluate(EvaluationContext vars) { + throw new UnsupportedOperationException(getClass().getName()); + } + + /** + * The name of the token, for use in parse error messages. + */ + abstract String name(); + + /** + * A synthetic node that represents the end of the input. This node is the last one in the + * initial token string and also the last one in the parse tree. + */ + static final class EofNode extends TokenNode { + EofNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override + String name() { + return "end of file"; + } + } + + static final class EndTokenNode extends TokenNode { + EndTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#end"; + } + } + + /** + * A node in the parse tree representing a comment. Comments are introduced by {@code ##} and + * extend to the end of the line. The only reason for recording comment nodes is so that we can + * skip space between a comment and a following {@code #set}, to be compatible with Velocity + * behaviour. + */ + static class CommentTokenNode extends TokenNode { + CommentTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "##"; + } + } + + abstract static class IfOrElseIfTokenNode extends TokenNode { + final ExpressionNode condition; + + IfOrElseIfTokenNode(ExpressionNode condition) { + super(condition.resourceName, condition.lineNumber); + this.condition = condition; + } + } + + static final class IfTokenNode extends IfOrElseIfTokenNode { + IfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#if"; + } + } + + static final class ElseIfTokenNode extends IfOrElseIfTokenNode { + ElseIfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#elseif"; + } + } + + static final class ElseTokenNode extends TokenNode { + ElseTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#else"; + } + } + + static final class ForEachTokenNode extends TokenNode { + final String var; + final ExpressionNode collection; + + ForEachTokenNode(String var, ExpressionNode collection) { + super(collection.resourceName, collection.lineNumber); + this.var = var; + this.collection = collection; + } + + @Override String name() { + return "#foreach"; + } + } + + static final class NestedTokenNode extends TokenNode { + final ImmutableList<Node> nodes; + + NestedTokenNode(String resourceName, ImmutableList<Node> nodes) { + super(resourceName, 1); + this.nodes = nodes; + } + + @Override String name() { + return "#parse(\"" + resourceName + "\")"; + } + } + + static final class MacroDefinitionTokenNode extends TokenNode { + final String name; + final ImmutableList<String> parameterNames; + + MacroDefinitionTokenNode( + String resourceName, int lineNumber, String name, List<String> parameterNames) { + super(resourceName, lineNumber); + this.name = name; + this.parameterNames = ImmutableList.copyOf(parameterNames); + } + + @Override String name() { + return "#macro(" + name + ")"; + } + } +} + diff --git a/src/test/java/com/google/escapevelocity/ImmutableSetTest.java b/src/test/java/com/google/escapevelocity/ImmutableSetTest.java new file mode 100644 index 0000000..b0283dd --- /dev/null +++ b/src/test/java/com/google/escapevelocity/ImmutableSetTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class ImmutableSetTest { + @Test + public void empty() { + ImmutableSet<String> empty = ImmutableSet.of(); + assertThat(empty).isEmpty(); + assertThat(empty).doesNotContain(""); + } + + @Test + public void duplicates() { + ImmutableSet<Integer> ints = ImmutableSet.of(1, 2, 3, 2, 1, 2, 3, 3); + assertThat(ints).hasSize(3); + assertThat(ints).containsExactly(1, 2, 3); + + ImmutableSet<Integer> ints2 = ImmutableSet.of(1, 2, 3, 4, 5, 3); + assertThat(ints2).containsExactly(1, 2, 3, 4, 5); + } +} diff --git a/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java new file mode 100644 index 0000000..660c237 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Primitives; +import com.google.common.truth.Expect; +import com.google.escapevelocity.ReferenceNode.MethodReferenceNode; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for {@link ReferenceNode}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class ReferenceNodeTest { + @Rule public Expect expect = Expect.create(); + + // This is the exhaustive list from + // https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2. + // We put the "from" type first for consistency with that list, even though that is inconsistent + // with our method order (which is itself consistent with assignment, "to" on the left). + private static final ImmutableSet<ImmutableList<Class<?>>> ASSIGNMENT_COMPATIBLE = + makeAssignmentCompatibleSet(); + private static ImmutableSet<ImmutableList<Class<?>>> makeAssignmentCompatibleSet() { + Class<?>[][] pairs = { + {byte.class, short.class}, + {byte.class, int.class}, + {byte.class, long.class}, + {byte.class, float.class}, + {byte.class, double.class}, + {short.class, int.class}, + {short.class, long.class}, + {short.class, float.class}, + {short.class, double.class}, + {char.class, int.class}, + {char.class, long.class}, + {char.class, float.class}, + {char.class, double.class}, + {int.class, long.class}, + {int.class, float.class}, + {int.class, double.class}, + {long.class, float.class}, + {long.class, double.class}, + {float.class, double.class}, + }; + ImmutableSet.Builder<ImmutableList<Class<?>>> builder = ImmutableSet.builder(); + for (Class<?>[] pair : pairs) { + builder.add(ImmutableList.copyOf(pair)); + } + return builder.build(); + } + + @Test + public void testPrimitiveTypeIsAssignmentCompatible() { + for (Class<?> from : Primitives.allPrimitiveTypes()) { + for (Class<?> to : Primitives.allPrimitiveTypes()) { + boolean expected = + (from == to || ASSIGNMENT_COMPATIBLE.contains(ImmutableList.of(from, to))); + boolean actual = + MethodReferenceNode.primitiveTypeIsAssignmentCompatible(to, from); + expect + .withMessage(from + " assignable to " + to) + .that(expected).isEqualTo(actual); + } + } + } + + @Test + public void testVisibleMethod() throws Exception { + Map<String, String> map = Collections.singletonMap("foo", "bar"); + Class<?> mapClass = map.getClass(); + assertThat(Modifier.isPublic(mapClass.getModifiers())).isFalse(); + Method size = map.getClass().getMethod("size"); + Method visibleSize = ReferenceNode.visibleMethod(size, mapClass); + assertThat(visibleSize.invoke(map)).isEqualTo(1); + } + + @Test + public void testCompatibleArgs() { + assertThat(MethodReferenceNode.compatibleArgs( + new Class<?>[]{int.class}, ImmutableList.of((Object) 5))).isTrue(); + } +} diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java new file mode 100644 index 0000000..bd769d6 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/TemplateTest.java @@ -0,0 +1,653 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.truth.Expect; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.RuntimeInstance; +import org.apache.velocity.runtime.log.NullLogChute; +import org.apache.velocity.runtime.parser.node.SimpleNode; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class TemplateTest { + @Rule public TestName testName = new TestName(); + @Rule public Expect expect = Expect.create(); + @Rule public ExpectedException thrown = ExpectedException.none(); + + private RuntimeInstance velocityRuntimeInstance; + + @Before + public void setUp() { + velocityRuntimeInstance = new RuntimeInstance(); + + // Ensure that $undefinedvar will produce an exception rather than outputting $undefinedvar. + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true"); + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, + new NullLogChute()); + + // Disable any logging that Velocity might otherwise see fit to do. + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute()); + + velocityRuntimeInstance.init(); + } + + private void compare(String template) { + compare(template, ImmutableMap.<String, Object>of()); + } + + private void compare(String template, Map<String, ?> vars) { + compare(template, Suppliers.ofInstance(vars)); + } + + /** + * Checks that the given template and the given variables produce identical results with + * Velocity and EscapeVelocity. This uses a {@code Supplier} to define the variables to cover + * test cases that involve modifying the values of the variables. Otherwise the run using + * Velocity would change those values so that the run using EscapeVelocity would not be starting + * from the same point. + */ + private void compare(String template, Supplier<? extends Map<String, ?>> varsSupplier) { + Map<String, ?> velocityVars = varsSupplier.get(); + String velocityRendered = velocityRender(template, velocityVars); + Map<String, ?> escapeVelocityVars = varsSupplier.get(); + String escapeVelocityRendered; + try { + escapeVelocityRendered = + Template.parseFrom(new StringReader(template)).evaluate(escapeVelocityVars); + } catch (IOException e) { + throw new AssertionError(e); + } + String failure = "from velocity: <" + velocityRendered + ">\n" + + "from escape velocity: <" + escapeVelocityRendered + ">\n"; + expect.withMessage(failure).that(escapeVelocityRendered).isEqualTo(velocityRendered); + } + + private String velocityRender(String template, Map<String, ?> vars) { + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + StringWriter writer = new StringWriter(); + SimpleNode parsedTemplate; + try { + parsedTemplate = velocityRuntimeInstance.parse( + new StringReader(template), testName.getMethodName()); + } catch (org.apache.velocity.runtime.parser.ParseException e) { + throw new AssertionError(e); + } + boolean rendered = velocityRuntimeInstance.render( + velocityContext, writer, parsedTemplate.getTemplateName(), parsedTemplate); + assertThat(rendered).isTrue(); + return writer.toString(); + } + + @Test + public void empty() { + compare(""); + } + + @Test + public void literalOnly() { + compare("In the reign of James the Second \n It was generally reckoned\n"); + } + + @Test + public void comment() { + compare("line 1 ##\n line 2"); + } + + @Test + public void substituteNoBraces() { + compare(" $x ", ImmutableMap.of("x", 1729)); + compare(" ! $x ! ", ImmutableMap.of("x", 1729)); + } + + @Test + public void substituteWithBraces() { + compare("a${x}\nb", ImmutableMap.of("x", "1729")); + } + + @Test + public void substitutePropertyNoBraces() { + compare("=$t.name=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substitutePropertyWithBraces() { + compare("=${t.name}=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteNestedProperty() { + compare("\n$t.name.empty\n", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteMethodNoArgs() { + compare("<$c.size()>", ImmutableMap.of("c", ImmutableMap.of())); + } + + @Test + public void substituteMethodOneArg() { + compare("<$list.get(0)>", ImmutableMap.of("list", ImmutableList.of("foo"))); + } + + @Test + public void substituteMethodTwoArgs() { + compare("\n$s.indexOf(\"bar\", 2)\n", ImmutableMap.of("s", "barbarbar")); + } + + @Test + public void substituteMethodNoSynthetic() { + // If we aren't careful, we'll see both the inherited `Set<K> keySet()` from Map + // and the overridden `ImmutableSet<K> keySet()` in ImmutableMap. + compare("$map.keySet()", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + } + + @Test + public void substituteIndexNoBraces() { + compare("<$map[\"x\"]>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexWithBraces() { + compare("<${map[\"x\"]}>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexThenProperty() { + compare("<$map[2].name>", ImmutableMap.of("map", ImmutableMap.of(2, getClass()))); + } + + @Test + public void variableNameCantStartWithNonAscii() { + compare("<$Éamonn>", ImmutableMap.<String, Object>of()); + } + + @Test + public void variableNamesAreAscii() { + compare("<$Pádraig>", ImmutableMap.of("P", "(P)")); + } + + @Test + public void variableNameCharacters() { + compare("<AZaz-foo_bar23>", ImmutableMap.of("AZaz-foo_bar23", "(P)")); + } + + public static class Indexable { + public String get(String y) { + return "[" + y + "]"; + } + } + + @Test + public void substituteExoticIndex() { + // Any class with a get(X) method can be used with $x[i] + compare("<$x[\"foo\"]>", ImmutableMap.of("x", new Indexable())); + } + + @Test + public void simpleSet() { + compare("$x#set ($x = 17)#set ($y = 23) ($x, $y)", ImmutableMap.of("x", 1)); + } + + @Test + public void newlineAfterSet() { + compare("foo #set ($x = 17)\nbar", ImmutableMap.<String, Object>of()); + } + + @Test + public void newlineInSet() { + compare("foo #set ($x\n = 17)\nbar $x", ImmutableMap.<String, Object>of()); + } + + @Test + public void expressions() { + compare("#set ($x = 1 + 1) $x"); + compare("#set ($x = 1 + 2 * 3) $x"); + compare("#set ($x = (1 + 1 == 2)) $x"); + compare("#set ($x = (1 + 1 != 2)) $x"); + compare("#set ($x = 22 - 7) $x"); + compare("#set ($x = 22 / 7) $x"); + compare("#set ($x = 22 % 7) $x"); + } + + @Test + public void associativity() { + compare("#set ($x = 3 - 2 - 1) $x"); + compare("#set ($x = 16 / 4 / 4) $x"); + } + + @Test + public void precedence() { + compare("#set ($x = 1 + 2 + 3 * 4 * 5 + 6) $x"); + compare("#set($x=1+2+3*4*5+6)$x"); + compare("#set ($x = 1 + 2 * 3 == 3 * 2 + 1) $x"); + } + + @Test + public void and() { + compare("#set ($x = false && false) $x"); + compare("#set ($x = false && true) $x"); + compare("#set ($x = true && false) $x"); + compare("#set ($x = true && true) $x"); + } + + @Test + public void or() { + compare("#set ($x = false || false) $x"); + compare("#set ($x = false || true) $x"); + compare("#set ($x = true || false) $x"); + compare("#set ($x = true || true) $x"); + } + + @Test + public void not() { + compare("#set ($x = !true) $x"); + compare("#set ($x = !false) $x"); + } + + @Test + public void truthValues() { + compare("#set ($x = $true && true) $x", ImmutableMap.of("true", true)); + compare("#set ($x = $false && true) $x", ImmutableMap.of("false", false)); + compare("#set ($x = $emptyCollection && true) $x", + ImmutableMap.of("emptyCollection", ImmutableList.of())); + compare("#set ($x = $emptyString && true) $x", ImmutableMap.of("emptyString", "")); + } + + @Test + public void numbers() { + compare("#set ($x = 0) $x"); + compare("#set ($x = -1) $x"); + compare("#set ($x = " + Integer.MAX_VALUE + ") $x"); + compare("#set ($x = " + Integer.MIN_VALUE + ") $x"); + } + + private static final String[] RELATIONS = {"==", "!=", "<", ">", "<=", ">="}; + + @Test + public void intRelations() { + int[] numbers = {-1, 0, 1, 17}; + for (String relation : RELATIONS) { + for (int a : numbers) { + for (int b : numbers) { + compare("#set ($x = $a " + relation + " $b) $x", + ImmutableMap.<String, Object>of("a", a, "b", b)); + } + } + } + } + + @Test + public void relationPrecedence() { + compare("#set ($x = 1 < 2 == 2 < 1) $x"); + compare("#set ($x = 2 < 1 == 2 < 1) $x"); + } + + /** + * Tests the surprising definition of equality mentioned in + * {@link ExpressionNode.EqualsExpressionNode}. + */ + @Test + public void funkyEquals() { + compare("#set ($t = (123 == \"123\")) $t"); + compare("#set ($f = (123 == \"1234\")) $f"); + compare("#set ($x = ($sb1 == $sb2)) $x", ImmutableMap.of( + "sb1", (Object) new StringBuilder("123"), + "sb2", (Object) new StringBuilder("123"))); + } + + @Test + public void ifTrueNoElse() { + compare("x#if (true)y #end z"); + compare("x#if (true)y #end z"); + compare("x#if (true)y #end\nz"); + compare("x#if (true)y #end\n z"); + compare("x#if (true) y #end\nz"); + compare("x#if (true)\ny #end\nz"); + compare("x#if (true) y #end\nz"); + compare("$x #if (true) y #end $x ", ImmutableMap.of("x", "!")); + } + + @Test + public void ifFalseNoElse() { + compare("x#if (false)y #end z"); + compare("x#if (false)y #end\nz"); + compare("x#if (false)y #end\n z"); + compare("x#if (false) y #end\nz"); + compare("x#if (false)\ny #end\nz"); + compare("x#if (false) y #end\nz"); + } + + @Test + public void ifTrueWithElse() { + compare("x#if (true) a #else b #end z"); + } + + @Test + public void ifFalseWithElse() { + compare("x#if (false) a #else b #end z"); + } + + @Test + public void ifTrueWithElseIf() { + compare("x#if (true) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfTrue() { + compare("x#if (false) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfFalse() { + compare("x#if (false) a #elseif (false) b #else c #end z"); + } + + @Test + public void ifBraces() { + compare("x#{if}(false)a#{elseif}(false)b #{else}c#{end}z"); + } + @Test + public void ifUndefined() { + compare("#if ($undefined) really? #else indeed #end"); + } + + @Test + public void forEach() { + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", new String[] {"foo", "bar", "baz"})); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableMap.of("foo", "bar", "baz", "buh"))); + } + + @Test + public void forEachHasNext() { + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + } + + @Test + public void nestedForEach() { + String template = + "$x #foreach ($x in $listOfLists)\n" + + " #foreach ($y in $x)\n" + + " ($y)#if ($foreach.hasNext), #end\n" + + " #end#if ($foreach.hasNext); #end\n" + + "#end\n" + + "$x\n"; + Object listOfLists = ImmutableList.of( + ImmutableList.of("foo", "bar", "baz"), ImmutableList.of("fred", "jim", "sheila")); + compare(template, ImmutableMap.of("x", 23, "listOfLists", listOfLists)); + } + + @Test + public void forEachScope() { + String template = + "$x #foreach ($x in $list)\n" + + "[$x]\n" + + "#set ($x = \"bar\")\n" + + "#set ($othervar = \"baz\")\n" + + "#end\n" + + "$x $othervar"; + compare( + template, ImmutableMap.of("x", "foo", "list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void setSpacing() { + // The spacing in the output from #set is eccentric. + compare("x#set ($x = 0)x"); + compare("x #set ($x = 0)x"); + compare("x #set ($x = 0) x"); + compare("$x#set ($x = 0)x", ImmutableMap.of("x", "!")); + + // Velocity WTF: the #set eats the space after $x and other references, so the output is <!x>. + compare("$x #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.length() #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.empty #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x[0] #set ($x = 0)x", ImmutableMap.of("x", ImmutableList.of("!"))); + + compare("x#set ($x = 0)\n $x!"); + + compare("x #set($x = 0) #set($x = 0) #set($x = 0) y"); + + compare("x ## comment\n #set($x = 0) y"); + } + + @Test + public void simpleMacro() { + String template = + "xyz\n" + + "#macro (m)\n" + + "hello world\n" + + "#end\n" + + "#m() abc #m()\n"; + compare(template); + } + + @Test + public void macroWithArgs() { + String template = + "$x\n" + + "#macro (m $x $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + /** + * Tests defining a macro inside a conditional. This proves that macros are not evaluated in the + * main control flow, but rather are extracted at parse time. It also tests what happens if there + * is more than one definition of the same macro. (It is not apparent from the test, but it is the + * first definition that is retained.) + */ + @Test + public void conditionalMacroDefinition() { + String templateFalse = + "#if (false)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateFalse); + + String templateTrue = + "#if (true)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateTrue); + } + + /** + * Tests referencing a macro before it is defined. Since macros are extracted at parse time but + * references are only used at evaluation time, this works. + */ + @Test + public void forwardMacroReference() { + String template = + "#m(17)\n" + + "#macro (m $x)\n" + + " !$x!\n" + + "#end"; + compare(template); + } + + @Test + public void macroArgsSeparatedBySpaces() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0] $list.get(1) 5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + @Test + public void macroArgsSeparatedByCommas() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0],$list.get(1),5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + // The following tests are based on http://wiki.apache.org/velocity/MacroEvaluationStrategy. + // They verify some of the trickier details of Velocity's call-by-name semantics. + + @Test + public void callBySharing() { + // The example on the web page is wrong because $map.put('x', 'a') evaluates to null, which + // Velocity rejects as a render error. We fix this by ensuring that the returned previous value + // is not null. + // Here, the value of $y should not be affected by #set($x = "a"), even though the name passed + // to $x is $y. + String template = + "#macro(callBySharing $x $map)\n" + + " #set($x = \"a\")\n" + + " $map.put(\"x\", \"a\")\n" + + "#end\n" + + "#callBySharing($y $map)\n" + + "y is $y\n" + + "map[x] is $map[\"x\"]\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of( + "y", "y", + "map", new HashMap<String, Object>(ImmutableMap.of("x", (Object) "foo"))); + } + }; + compare(template, makeMap); + } + + @Test + public void callByMacro() { + // Since #callByMacro1 never references its argument, $x.add("t") is never evaluated during it. + // Since #callByMacro2 references its argument twice, $x.add("t") is evaluated twice during it. + String template = + "#macro(callByMacro1 $p)\n" + + " not using\n" + + "#end\n" + + "#macro(callByMacro2 $p)\n" + + " using: $p\n" + + " using again: $p\n" + + " using again: $p\n" + + "#end\n" + + "#callByMacro1($x.add(\"t\"))\n" + + "x = $x\n" + + "#callByMacro2($x.add(\"t\"))\n" + + "x = $x\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of("x", new ArrayList<Object>()); + } + }; + compare(template, makeMap); + } + + @Test + public void callByValue() { + // The assignments to the macro parameters $a and $b cause those parameters to be shadowed, + // so the output is: a b becomes b a. + String template = + "#macro(callByValueSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end" + + "#callByValueSwap(\"a\", \"b\")"; + compare(template); + } + + // First "Call by macro expansion example" doesn't apply as long as we don't have map literals. + + @Test + public void nameCaptureSwap() { + // Here, the arguments $a and $b are variables rather than literals, which means that their + // values change when we set those variables. #set($tmp = $a) changes the meaning of $b since + // $b is the name $tmp. So #set($a = $b) shadows parameter $a with the value of $tmp, which we + // have just set to "a". Then #set($b = $tmp) shadows parameter $b also with the value of $tmp. + // The end result is: a b becomes a a. + String template = + "#macro(nameCaptureSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end\n" + + "#set($x = \"a\")\n" + + "#set($tmp = \"b\")\n" + + "#nameCaptureSwap($x $tmp)"; + compare(template); + } + + @Test + public void undefinedMacro() throws IOException { + String template = "#oops()"; + thrown.expect(ParseException.class); + thrown.expectMessage("#oops is neither a standard directive nor a macro that has been defined"); + Template.parseFrom(new StringReader(template)); + } + + @Test + public void macroArgumentMismatch() throws IOException { + String template = + "#macro (twoArgs $a $b) $a $b #end\n" + + "#twoArgs(23)\n"; + thrown.expect(ParseException.class); + thrown.expectMessage("Wrong number of arguments to #twoArgs: expected 2, got 1"); + Template.parseFrom(new StringReader(template)); + } + +} |