diff options
author | Colin Cross <ccross@android.com> | 2020-06-19 06:18:09 +0000 |
---|---|---|
committer | Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> | 2020-06-19 06:18:09 +0000 |
commit | 6ddc2e77d3a9e66174e0344e8f17338a511d4f03 (patch) | |
tree | 2f8cc743bd7babbb6fa85cd2b98e4e5e46c847f8 /src/test/java/com/google/escapevelocity/TemplateTest.java | |
parent | 4e70048b1a58f2f479f6d9179eecb395edb95522 (diff) | |
parent | cc05f1b8c032da2771bf110e01f36e51bbf29f2e (diff) | |
download | escapevelocity-6ddc2e77d3a9e66174e0344e8f17338a511d4f03.tar.gz |
Merge tag 'escapevelocity-0.9.1' into master am: 43799cbf40 am: 6b276f51ce am: f29fb6ed5e am: cc05f1b8c0
Original change: https://android-review.googlesource.com/c/platform/external/escapevelocity/+/1343296
Change-Id: If2c33d268d0adf811a08ef5ab12744fc4a3aebd8
Diffstat (limited to 'src/test/java/com/google/escapevelocity/TemplateTest.java')
-rw-r--r-- | src/test/java/com/google/escapevelocity/TemplateTest.java | 954 |
1 files changed, 954 insertions, 0 deletions
diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java new file mode 100644 index 0000000..0503125 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/TemplateTest.java @@ -0,0 +1,954 @@ +/* + * Copyright (C) 2018 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.fail; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.truth.Expect; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Supplier; +import org.apache.commons.collections.ExtendedProperties; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.exception.ResourceNotFoundException; +import org.apache.velocity.exception.VelocityException; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.RuntimeInstance; +import org.apache.velocity.runtime.log.NullLogChute; +import org.apache.velocity.runtime.parser.node.SimpleNode; +import org.apache.velocity.runtime.resource.Resource; +import org.apache.velocity.runtime.resource.loader.ResourceLoader; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class TemplateTest { + @Rule public TestName testName = new TestName(); + @Rule public Expect expect = Expect.create(); + + private RuntimeInstance velocityRuntimeInstance; + + @Before + public void initVelocityRuntimeInstance() { + velocityRuntimeInstance = newVelocityRuntimeInstance(); + velocityRuntimeInstance.init(); + } + + private RuntimeInstance newVelocityRuntimeInstance() { + RuntimeInstance runtimeInstance = new RuntimeInstance(); + + // Ensure that $undefinedvar will produce an exception rather than outputting $undefinedvar. + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true"); + + // Disable any logging that Velocity might otherwise see fit to do. + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, new NullLogChute()); + runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute()); + return runtimeInstance; + } + + private void compare(String template) { + compare(template, ImmutableMap.<String, Object>of()); + } + + private void compare(String template, Map<String, ?> vars) { + compare(template, () -> vars); + } + + /** + * Checks that the given template and the given variables produce identical results with + * Velocity and EscapeVelocity. This uses a {@code Supplier} to define the variables to cover + * test cases that involve modifying the values of the variables. Otherwise the run using + * Velocity would change those values so that the run using EscapeVelocity would not be starting + * from the same point. + */ + private void compare(String template, Supplier<? extends Map<String, ?>> varsSupplier) { + Map<String, ?> velocityVars = varsSupplier.get(); + String velocityRendered = velocityRender(template, velocityVars); + Map<String, ?> escapeVelocityVars = varsSupplier.get(); + String escapeVelocityRendered; + try { + escapeVelocityRendered = + Template.parseFrom(new StringReader(template)).evaluate(escapeVelocityVars); + } catch (Exception e) { + throw new AssertionError( + "EscapeVelocity failed, but Velocity succeeded and returned: <" + velocityRendered + ">", + e); + } + String failure = "from Velocity: <" + velocityRendered + ">\n" + + "from EscapeVelocity: <" + escapeVelocityRendered + ">\n"; + expect.withMessage(failure).that(escapeVelocityRendered).isEqualTo(velocityRendered); + } + + private String velocityRender(String template, Map<String, ?> vars) { + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + StringWriter writer = new StringWriter(); + SimpleNode parsedTemplate; + try { + parsedTemplate = velocityRuntimeInstance.parse( + new StringReader(template), testName.getMethodName()); + } catch (org.apache.velocity.runtime.parser.ParseException e) { + throw new AssertionError(e); + } + boolean rendered = velocityRuntimeInstance.render( + velocityContext, writer, parsedTemplate.getTemplateName(), parsedTemplate); + assertThat(rendered).isTrue(); + return writer.toString(); + } + + private void expectParseException( + String template, + String expectedMessageSubstring) { + Exception velocityException = null; + try { + SimpleNode parsedTemplate = + velocityRuntimeInstance.parse(new StringReader(template), testName.getMethodName()); + VelocityContext velocityContext = new VelocityContext(new TreeMap<>()); + velocityRuntimeInstance.render( + velocityContext, new StringWriter(), parsedTemplate.getTemplateName(), parsedTemplate); + fail("Velocity did not throw an exception for this template"); + } catch (org.apache.velocity.runtime.parser.ParseException | VelocityException expected) { + velocityException = expected; + } + try { + Template.parseFrom(new StringReader(template)); + fail("Velocity generated an exception, but EscapeVelocity did not: " + velocityException); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (ParseException expected) { + assertWithMessage("Got expected exception, but message did not match") + .that(expected).hasMessageThat().contains(expectedMessageSubstring); + } + } + + @Test + public void empty() { + compare(""); + } + + @Test + public void literalOnly() { + compare("In the reign of James the Second \n It was generally reckoned\n"); + } + + @Test + public void lineComment() { + compare("line 1 ##\n line 2"); + } + + @Test + public void blockComment() { + compare("line 1 #* blah\n line 2 * #\n line 3 *# \n line 4"); + compare("foo #*# bar *# baz"); + compare("foo #* one *# #* two *# #* three *#"); + compare("foo #** bar *# #* baz **#"); + } + + @Test + public void ignoreHashIfNotDirectiveOrComment() { + compare("# if is not a directive because of the space"); + compare("#<foo>"); + compare("# <foo>"); + compare("${foo}#${bar}", ImmutableMap.of("foo", "xxx", "bar", "yyy")); + } + + @Test + public void blockQuote() { + compare("#[[]]#"); + compare("x#[[]]#y"); + compare("#[[$notAReference #notADirective]]#"); + compare("#[[ [[ ]] ]# ]]#"); + compare("#[ foo"); + compare("x\n #[[foo\nbar\nbaz]]#y"); + } + + @Test + public void substituteNoBraces() { + compare(" $x ", ImmutableMap.of("x", 1729)); + compare(" ! $x ! ", ImmutableMap.of("x", 1729)); + } + + @Test + public void dollarWithoutId() { + compare(" $? "); + compare(" $$ "); + compare(" $. "); + compare(" $[ "); + } + + @Test + public void doubleDollar() { + // The first $ is plain text and the second one starts a reference. + compare(" $$foo ", ImmutableMap.of("foo", true)); + compare(" $${foo} ", ImmutableMap.of("foo", true)); + } + + @Test + public void substituteWithBraces() { + compare("a${x}\nb", ImmutableMap.of("x", "1729")); + } + + @Test + public void substitutePropertyNoBraces() { + compare("=$t.name=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substitutePropertyWithBraces() { + compare("=${t.name}=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteNotPropertyId() { + compare("$foo.!", ImmutableMap.of("foo", false)); + } + + @Test + public void substituteNestedProperty() { + compare("\n$t.name.empty\n", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteMethodNoArgs() { + compare("<$c.size()>", ImmutableMap.of("c", ImmutableMap.of())); + } + + @Test + public void substituteMethodNoArgsSyntheticOverride() { + compare("<$c.isEmpty()>", ImmutableMap.of("c", ImmutableSetMultimap.of())); + } + + @Test + public void substituteMethodOneArg() { + compare("<$list.get(0)>", ImmutableMap.of("list", ImmutableList.of("foo"))); + } + + @Test + public void substituteMethodOneNullArg() { + // This should evaluate map.containsKey(map.get("absent")), which is map.containsKey(null). + compare("<$map.containsKey($map.get(\"absent\"))>", ImmutableMap.of("map", ImmutableMap.of())); + } + + @Test + public void substituteMethodTwoArgs() { + compare("\n$s.indexOf(\"bar\", 2)\n", ImmutableMap.of("s", "barbarbar")); + } + + @Test + public void substituteMethodSyntheticOverloads() { + // If we aren't careful, we'll see both the inherited `Set<K> keySet()` from Map + // and the overridden `ImmutableSet<K> keySet()` in ImmutableMap. + compare("$map.keySet()", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + } + + @Test + public void substituteStaticMethod() { + compare("$Integer.toHexString(23)", ImmutableMap.of("Integer", Integer.class)); + } + + @Test + public void substituteStaticMethodAsInstanceMethod() { + compare("$i.toHexString(23)", ImmutableMap.of("i", 0)); + } + + @Test + public void substituteClassMethod() { + // This is Class.getName(). + compare("$Integer.getName()", ImmutableMap.of("Integer", Integer.class)); + } + + /** See {@link #substituteClassOrInstanceMethod}. */ + public static class GetName { + public static String getName() { + return "Noddy"; + } + } + + @Test + public void substituteClassOrInstanceMethod() { + // If the method exists as both an instance method on Class and a static method on the named + // class, it's the instance method that wins, so this is still Class.getName(). + compare("$GetName.getName()", ImmutableMap.of("GetName", GetName.class)); + } + + @Test + public void substituteIndexNoBraces() { + compare("<$map[\"x\"]>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexWithBraces() { + compare("<${map[\"x\"]}>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + // Velocity allows you to write $map.foo instead of $map["foo"]. + @Test + public void substituteMapProperty() { + compare("$map.foo", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + // $map.empty is always equivalent to $map["empty"], never Map.isEmpty(). + compare("$map.empty", ImmutableMap.of("map", ImmutableMap.of("empty", "foo"))); + } + + @Test + public void substituteIndexThenProperty() { + compare("<$map[2].name>", ImmutableMap.of("map", ImmutableMap.of(2, getClass()))); + } + + @Test + public void variableNameCantStartWithNonAscii() { + compare("<$Éamonn>", ImmutableMap.<String, Object>of()); + } + + @Test + public void variableNamesAreAscii() { + compare("<$Pádraig>", ImmutableMap.of("P", "(P)")); + } + + @Test + public void variableNameCharacters() { + compare("<AZaz-foo_bar23>", ImmutableMap.of("AZaz-foo_bar23", "(P)")); + } + + /** + * A public class with a public {@code get} method that has one argument. That means instances can + * be used like {@code $indexable["foo"]}. + */ + public static class Indexable { + public String get(String y) { + return "[" + y + "]"; + } + } + + @Test + public void substituteExoticIndex() { + // Any class with a get(X) method can be used with $x[i] + compare("<$x[\"foo\"]>", ImmutableMap.of("x", new Indexable())); + } + + @Test + public void substituteInString() { + String template = + "#foreach ($a in $list)" + + "#set ($s = \"THING_${foreach.index}\")" + + "$s,$s;" + + "#end"; + compare(template, ImmutableMap.of("list", ImmutableList.of(1, 2, 3))); + compare("#set ($s = \"$x\") <$s>", ImmutableMap.of("x", "fred")); + compare("#set ($s = \"==$x$y\") <$s>", ImmutableMap.of("x", "fred", "y", "jim")); + compare("#set ($s = \"$x$y==\") <$s>", ImmutableMap.of("x", "fred", "y", "jim")); + } + + @Test + public void stringOperationsOnSubstitution() { + compare("#set ($s = \"a${b}c\") $s.length()", ImmutableMap.of("b", 23)); + } + + @Test + public void singleQuoteNoSubstitution() { + compare("#set ($s = 'a${b}c') x${s}y", ImmutableMap.of("b", 23)); + } + + @Test + public void simpleSet() { + compare("$x#set ($x = 17)#set ($y = 23) ($x, $y)", ImmutableMap.of("x", 1)); + } + + @Test + public void newlineAfterSet() { + compare("foo #set ($x = 17)\nbar", ImmutableMap.<String, Object>of()); + } + + @Test + public void newlineInSet() { + compare("foo #set ($x\n = 17)\nbar $x", ImmutableMap.<String, Object>of()); + } + + @Test + public void expressions() { + compare("#set ($x = 1 + 1) $x"); + compare("#set ($x = 1 + 2 * 3) $x"); + compare("#set ($x = (1 + 1 == 2)) $x"); + compare("#set ($x = (1 + 1 != 2)) $x"); + compare("#set ($x = 22 - 7) $x"); + compare("#set ($x = 22 / 7) $x"); + compare("#set ($x = 22 % 7) $x"); + } + + @Test + public void associativity() { + compare("#set ($x = 3 - 2 - 1) $x"); + compare("#set ($x = 16 / 4 / 4) $x"); + } + + @Test + public void precedence() { + compare("#set ($x = 1 + 2 + 3 * 4 * 5 + 6) $x"); + compare("#set($x=1+2+3*4*5+6)$x"); + compare("#set ($x = 1 + 2 * 3 == 3 * 2 + 1) $x"); + } + + @Test + public void and() { + compare("#set ($x = false && false) $x"); + compare("#set ($x = false && true) $x"); + compare("#set ($x = true && false) $x"); + compare("#set ($x = true && true) $x"); + } + + @Test + public void or() { + compare("#set ($x = false || false) $x"); + compare("#set ($x = false || true) $x"); + compare("#set ($x = true || false) $x"); + compare("#set ($x = true || true) $x"); + } + + @Test + public void not() { + compare("#set ($x = !true) $x"); + compare("#set ($x = !false) $x"); + } + + @Test + public void truthValues() { + compare("#set ($x = $true && true) $x", ImmutableMap.of("true", true)); + compare("#set ($x = $false && true) $x", ImmutableMap.of("false", false)); + compare("#set ($x = $emptyCollection && true) $x", + ImmutableMap.of("emptyCollection", ImmutableList.of())); + compare("#set ($x = $emptyString && true) $x", ImmutableMap.of("emptyString", "")); + } + + @Test + public void numbers() { + compare("#set ($x = 0) $x"); + compare("#set ($x = -1) $x"); + compare("#set ($x = " + Integer.MAX_VALUE + ") $x"); + compare("#set ($x = " + Integer.MIN_VALUE + ") $x"); + } + + private static final String[] RELATIONS = {"==", "!=", "<", ">", "<=", ">="}; + + @Test + public void intRelations() { + int[] numbers = {-1, 0, 1, 17}; + for (String relation : RELATIONS) { + for (int a : numbers) { + for (int b : numbers) { + compare("#set ($x = $a " + relation + " $b) $x", + ImmutableMap.<String, Object>of("a", a, "b", b)); + } + } + } + } + + @Test + public void relationPrecedence() { + compare("#set ($x = 1 < 2 == 2 < 1) $x"); + compare("#set ($x = 2 < 1 == 2 < 1) $x"); + } + + /** + * Tests the surprising definition of equality mentioned in + * {@link ExpressionNode.BinaryExpressionNode}. + */ + @Test + public void funkyEquals() { + compare("#set ($t = (123 == \"123\")) $t"); + compare("#set ($f = (123 == \"1234\")) $f"); + compare("#set ($x = ($sb1 == $sb2)) $x", ImmutableMap.of( + "sb1", (Object) new StringBuilder("123"), + "sb2", (Object) new StringBuilder("123"))); + } + + @Test + public void ifTrueNoElse() { + compare("x#if (true)y #end z"); + compare("x#if (true)y #end z"); + compare("x#if (true)y #end\nz"); + compare("x#if (true)y #end\n z"); + compare("x#if (true) y #end\nz"); + compare("x#if (true)\ny #end\nz"); + compare("x#if (true) y #end\nz"); + compare("$x #if (true) y #end $x ", ImmutableMap.of("x", "!")); + } + + @Test + public void ifFalseNoElse() { + compare("x#if (false)y #end z"); + compare("x#if (false)y #end\nz"); + compare("x#if (false)y #end\n z"); + compare("x#if (false) y #end\nz"); + compare("x#if (false)\ny #end\nz"); + compare("x#if (false) y #end\nz"); + } + + @Test + public void ifTrueWithElse() { + compare("x#if (true) a #else b #end z"); + } + + @Test + public void ifFalseWithElse() { + compare("x#if (false) a #else b #end z"); + } + + @Test + public void ifTrueWithElseIf() { + compare("x#if (true) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfTrue() { + compare("x#if (false) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfFalse() { + compare("x#if (false) a #elseif (false) b #else c #end z"); + } + + @Test + public void ifBraces() { + compare("x#{if}(false)a#{elseif}(false)b #{else}c#{end}z"); + } + @Test + public void ifUndefined() { + compare("#if ($undefined) really? #else indeed #end"); + } + + @Test + public void forEach() { + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", new String[] {"foo", "bar", "baz"})); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableMap.of("foo", "bar", "baz", "buh"))); + } + + @Test + public void forEachHasNext() { + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + } + + @Test + public void nestedForEach() { + String template = + "$x #foreach ($x in $listOfLists)\n" + + " #foreach ($y in $x)\n" + + " ($y)#if ($foreach.hasNext), #end\n" + + " #end#if ($foreach.hasNext); #end\n" + + "#end\n" + + "$x\n"; + Object listOfLists = ImmutableList.of( + ImmutableList.of("foo", "bar", "baz"), ImmutableList.of("fred", "jim", "sheila")); + compare(template, ImmutableMap.of("x", 23, "listOfLists", listOfLists)); + } + + @Test + public void forEachScope() { + String template = + "$x #foreach ($x in $list)\n" + + "[$x]\n" + + "#set ($x = \"bar\")\n" + + "#set ($othervar = \"baz\")\n" + + "#end\n" + + "$x $othervar"; + compare( + template, ImmutableMap.of("x", "foo", "list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void forEachIndex() { + String template = + "#foreach ($x in $list)" + + "[$foreach.index]" + + "#foreach ($y in $list)" + + "($foreach.index)==$x.$y==" + + "#end" + + "#end"; + compare(template, ImmutableMap.of("list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void setSpacing() { + // The spacing in the output from #set is eccentric. + compare("x#set ($x = 0)x"); + compare("x #set ($x = 0)x"); + compare("x #set ($x = 0) x"); + compare("$x#set ($x = 0)x", ImmutableMap.of("x", "!")); + + // Velocity WTF: the #set eats the space after $x and other references, so the output is <!x>. + compare("$x #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.length() #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.empty #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x[0] #set ($x = 0)x", ImmutableMap.of("x", ImmutableList.of("!"))); + + compare("x#set ($x = 0)\n $x!"); + + compare("x #set($x = 0) #set($x = 0) #set($x = 0) y"); + + compare("x ## comment\n #set($x = 0) y"); + compare("x #* comment *# #set($x = 0) y"); + } + + @Test + public void simpleMacro() { + String template = + "xyz\n" + + "#macro (m)\n" + + "hello world\n" + + "#end\n" + + "#m() abc #m()\n"; + compare(template); + } + + @Test + public void macroWithArgs() { + String template = + "$x\n" + + "#macro (m $x $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + @Test + public void macroWithCommaSeparatedArgs() { + String template = + "$x\n" + + "#macro (m, $x, $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + /** + * Tests defining a macro inside a conditional. This proves that macros are not evaluated in the + * main control flow, but rather are extracted at parse time. It also tests what happens if there + * is more than one definition of the same macro. (It is not apparent from the test, but it is the + * first definition that is retained.) + */ + @Test + public void conditionalMacroDefinition() { + String templateFalse = + "#if (false)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateFalse); + + String templateTrue = + "#if (true)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateTrue); + } + + /** + * Tests referencing a macro before it is defined. Since macros are extracted at parse time but + * references are only used at evaluation time, this works. + */ + @Test + public void forwardMacroReference() { + String template = + "#m(17)\n" + + "#macro (m $x)\n" + + " !$x!\n" + + "#end"; + compare(template); + } + + @Test + public void macroArgsSeparatedBySpaces() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0] $list.get(1) 5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + @Test + public void macroArgsSeparatedByCommas() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0],$list.get(1),5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + // The following tests are based on http://wiki.apache.org/velocity/MacroEvaluationStrategy. + // They verify some of the trickier details of Velocity's call-by-name semantics. + + @Test + public void callBySharing() { + // The example on the web page is wrong because $map.put('x', 'a') evaluates to null, which + // Velocity rejects as a render error. We fix this by ensuring that the returned previous value + // is not null. + // Here, the value of $y should not be affected by #set($x = "a"), even though the name passed + // to $x is $y. + String template = + "#macro(callBySharing $x $map)\n" + + " #set($x = \"a\")\n" + + " $map.put(\"x\", \"a\")\n" + + "#end\n" + + "#callBySharing($y $map)\n" + + "y is $y\n" + + "map[x] is $map[\"x\"]\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of( + "y", "y", + "map", new HashMap<String, Object>(ImmutableMap.of("x", (Object) "foo"))); + } + }; + compare(template, makeMap); + } + + @Test + public void callByMacro() { + // Since #callByMacro1 never references its argument, $x.add("t") is never evaluated during it. + // Since #callByMacro2 references its argument twice, $x.add("t") is evaluated twice during it. + String template = + "#macro(callByMacro1 $p)\n" + + " not using\n" + + "#end\n" + + "#macro(callByMacro2 $p)\n" + + " using: $p\n" + + " using again: $p\n" + + " using again: $p\n" + + "#end\n" + + "#callByMacro1($x.add(\"t\"))\n" + + "x = $x\n" + + "#callByMacro2($x.add(\"t\"))\n" + + "x = $x\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of("x", new ArrayList<Object>()); + } + }; + compare(template, makeMap); + } + + @Test + public void callByValue() { + // The assignments to the macro parameters $a and $b cause those parameters to be shadowed, + // so the output is: a b becomes b a. + String template = + "#macro(callByValueSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end" + + "#callByValueSwap(\"a\", \"b\")"; + compare(template); + } + + // First "Call by macro expansion example" doesn't apply as long as we don't have map literals. + + @Test + public void nameCaptureSwap() { + // Here, the arguments $a and $b are variables rather than literals, which means that their + // values change when we set those variables. #set($tmp = $a) changes the meaning of $b since + // $b is the name $tmp. So #set($a = $b) shadows parameter $a with the value of $tmp, which we + // have just set to "a". Then #set($b = $tmp) shadows parameter $b also with the value of $tmp. + // The end result is: a b becomes a a. + String template = + "#macro(nameCaptureSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end\n" + + "#set($x = \"a\")\n" + + "#set($tmp = \"b\")\n" + + "#nameCaptureSwap($x $tmp)"; + compare(template); + } + + @Test + public void badBraceReference() { + String template = "line 1\nline 2\nbar${foo.!}baz"; + expectParseException(template, "Expected }, on line 3, at text starting: .!}baz"); + } + + @Test + public void undefinedMacro() { + String template = "#oops()"; + expectParseException( + template, + "#oops is neither a standard directive nor a macro that has been defined"); + } + + @Test + public void macroArgumentMismatch() { + String template = + "#macro (twoArgs $a $b) $a $b #end\n" + + "#twoArgs(23)\n"; + expectParseException(template, "Wrong number of arguments to #twoArgs: expected 2, got 1"); + } + + @Test + public void unclosedBlockQuote() { + String template = "foo\nbar #[[\nblah\nblah"; + expectParseException(template, "Unterminated #[[ - did not see matching ]]#, on line 2"); + } + + @Test + public void unclosedBlockComment() { + compare("foo\nbar #*\nblah\nblah"); + } + + /** + * A Velocity ResourceLoader that looks resources up in a map. This allows us to test directives + * that read "resources", for example {@code #parse}, without needing to make separate files to + * put them in. + */ + private static final class MapResourceLoader extends ResourceLoader { + private final ImmutableMap<String, String> resourceMap; + + MapResourceLoader(ImmutableMap<String, String> resourceMap) { + this.resourceMap = resourceMap; + } + + @Override + public void init(ExtendedProperties configuration) { + } + + @Override + public InputStream getResourceStream(String source) { + String resource = resourceMap.get(source); + if (resource == null) { + throw new ResourceNotFoundException(source); + } + return new ByteArrayInputStream(resource.getBytes(StandardCharsets.ISO_8859_1)); + } + + @Override + public boolean isSourceModified(Resource resource) { + return false; + } + + @Override + public long getLastModified(Resource resource) { + return 0; + } + }; + + private String renderWithResources( + String templateResourceName, + ImmutableMap<String, String> resourceMap, + ImmutableMap<String, String> vars) { + MapResourceLoader mapResourceLoader = new MapResourceLoader(resourceMap); + RuntimeInstance runtimeInstance = newVelocityRuntimeInstance(); + runtimeInstance.setProperty("resource.loader", "map"); + runtimeInstance.setProperty("map.resource.loader.instance", mapResourceLoader); + runtimeInstance.init(); + org.apache.velocity.Template velocityTemplate = + runtimeInstance.getTemplate(templateResourceName); + StringWriter velocityWriter = new StringWriter(); + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + velocityTemplate.merge(velocityContext, velocityWriter); + return velocityWriter.toString(); + } + + @Test + public void parseDirective() throws IOException { + // If outer.vm does #parse("nested.vm"), then we should be able to #set a variable in + // nested.vm and use it in outer.vm, and we should be able to define a #macro in nested.vm + // and call it in outer.vm. + ImmutableMap<String, String> resources = ImmutableMap.of( + "outer.vm", + "first line\n" + + "#parse (\"nested.vm\")\n" + + "<#decorate (\"left\" \"right\")>\n" + + "$baz skidoo\n" + + "last line\n", + "nested.vm", + "nested template first line\n" + + "[#if ($foo == $bar) equal #else not equal #end]\n" + + "#macro (decorate $a $b) < $a | $b > #end\n" + + "#set ($baz = 23)\n" + + "nested template last line\n"); + + ImmutableMap<String, String> vars = ImmutableMap.of("foo", "foovalue", "bar", "barvalue"); + + String velocityResult = renderWithResources("outer.vm", resources, vars); + + Template.ResourceOpener resourceOpener = resourceName -> { + String resource = resources.get(resourceName); + if (resource == null) { + throw new FileNotFoundException(resourceName); + } + return new StringReader(resource); + }; + Template template = Template.parseFrom("outer.vm", resourceOpener); + + String result = template.evaluate(vars); + assertThat(result).isEqualTo(velocityResult); + + ImmutableMap<String, String> badVars = ImmutableMap.of("foo", "foovalue"); + try { + template.evaluate(badVars); + fail(); + } catch (EvaluationException e) { + assertThat(e).hasMessageThat().isEqualTo( + "In expression on line 2 of nested.vm: Undefined reference $bar"); + } + } +} |