Update to latest Google version of EscapeVelocity.
diff --git a/pom.xml b/pom.xml
index a58896d..b87abb1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,21 +26,18 @@
     A reimplementation of a subset of the Apache Velocity templating system.
   </description>
 
-  <!-- TODO(emcmanus)
   <scm>
-    <url>http://github.com/google/auto</url>
-    <connection>scm:git:git://github.com/google/auto.git</connection>
-    <developerConnection>scm:git:ssh://git@github.com/google/auto.git</developerConnection>
+    <url>http://github.com/google/escapevelocity</url>
+    <connection>scm:git:git://github.com/google/escapevelocity.git</connection>
+    <developerConnection>scm:git:ssh://git@github.com/google/escapevelocity.git</developerConnection>
     <tag>HEAD</tag>
   </scm>
-  -->
 
   <dependencies>
     <dependency>
       <groupId>com.google.guava</groupId>
       <artifactId>guava</artifactId>
       <version>23.5-jre</version>
-      <scope>test</scope>
     </dependency>
     <!-- test dependencies -->
     <dependency>
@@ -76,8 +73,8 @@
         <artifactId>maven-compiler-plugin</artifactId>
         <version>3.7.0</version>
         <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
+          <source>1.8</source>
+          <target>1.8</target>
           <compilerArgument>-Xlint:all</compilerArgument>
           <showWarnings>true</showWarnings>
           <showDeprecation>true</showDeprecation>
diff --git a/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java
index a4dfe17..982a4a9 100644
--- a/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java
+++ b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
 
 /**
diff --git a/src/main/java/com/google/escapevelocity/DirectiveNode.java b/src/main/java/com/google/escapevelocity/DirectiveNode.java
index cf33f55..db6a3a3 100644
--- a/src/main/java/com/google/escapevelocity/DirectiveNode.java
+++ b/src/main/java/com/google/escapevelocity/DirectiveNode.java
@@ -11,8 +11,29 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
 package com.google.escapevelocity;
 
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.Map;
@@ -186,7 +207,7 @@
 
     @Override
     Object evaluate(EvaluationContext context) {
-      assert macro != null : "Macro should have been linked: #" + name;
+      Verify.verifyNotNull(macro, "Macro #%s should have been linked", name);
       return macro.evaluate(context, thunks);
     }
   }
diff --git a/src/main/java/com/google/escapevelocity/EvaluationContext.java b/src/main/java/com/google/escapevelocity/EvaluationContext.java
index 43b7868..4c4b27d 100644
--- a/src/main/java/com/google/escapevelocity/EvaluationContext.java
+++ b/src/main/java/com/google/escapevelocity/EvaluationContext.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
@@ -60,17 +79,9 @@
       Runnable undo;
       if (vars.containsKey(var)) {
         final Object oldValue = vars.get(var);
-        undo = new Runnable() {
-          @Override public void run() {
-            vars.put(var, oldValue);
-          }
-        };
+        undo = () -> vars.put(var, oldValue);
       } else {
-        undo = new Runnable() {
-          @Override public void run() {
-            vars.remove(var);
-          }
-        };
+        undo = () -> 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
index 67aa15c..c64318c 100644
--- a/src/main/java/com/google/escapevelocity/EvaluationException.java
+++ b/src/main/java/com/google/escapevelocity/EvaluationException.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
 
 /**
diff --git a/src/main/java/com/google/escapevelocity/ExpressionNode.java b/src/main/java/com/google/escapevelocity/ExpressionNode.java
index 4ee29c5..e666ed1 100644
--- a/src/main/java/com/google/escapevelocity/ExpressionNode.java
+++ b/src/main/java/com/google/escapevelocity/ExpressionNode.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
diff --git a/src/main/java/com/google/escapevelocity/Macro.java b/src/main/java/com/google/escapevelocity/Macro.java
index 151ded2..fbb1764 100644
--- a/src/main/java/com/google/escapevelocity/Macro.java
+++ b/src/main/java/com/google/escapevelocity/Macro.java
@@ -11,8 +11,29 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
 package com.google.escapevelocity;
 
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -50,7 +71,7 @@
 
   Object evaluate(EvaluationContext context, List<Node> thunks) {
     try {
-      assert thunks.size() == parameterNames.size() : "Argument mistmatch for " + name;
+      Verify.verify(thunks.size() == parameterNames.size(), "Argument mistmatch for %s", name);
       Map<String, Node> parameterThunks = new LinkedHashMap<>();
       for (int i = 0; i < parameterNames.size(); i++) {
         parameterThunks.put(parameterNames.get(i), thunks.get(i));
@@ -121,12 +142,9 @@
       } else {
         parameterThunks.remove(var);
         final Runnable originalUndo = originalEvaluationContext.setVar(var, value);
-        return new Runnable() {
-          @Override
-          public void run() {
-            originalUndo.run();
-            parameterThunks.put(var, thunk);
-          }
+        return () -> {
+          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
index eca745f..d11c95d 100644
--- a/src/main/java/com/google/escapevelocity/Node.java
+++ b/src/main/java/com/google/escapevelocity/Node.java
@@ -11,8 +11,29 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
 package com.google.escapevelocity;
 
+import com.google.common.collect.ImmutableList;
+
 /**
  * A node in the parse tree.
  *
diff --git a/src/main/java/com/google/escapevelocity/ParseException.java b/src/main/java/com/google/escapevelocity/ParseException.java
index 241a192..9d4a39c 100644
--- a/src/main/java/com/google/escapevelocity/ParseException.java
+++ b/src/main/java/com/google/escapevelocity/ParseException.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
 
 /**
diff --git a/src/main/java/com/google/escapevelocity/Parser.java b/src/main/java/com/google/escapevelocity/Parser.java
index 9982be3..0beaf18 100644
--- a/src/main/java/com/google/escapevelocity/Parser.java
+++ b/src/main/java/com/google/escapevelocity/Parser.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
@@ -29,14 +48,16 @@
 import com.google.escapevelocity.TokenNode.IfTokenNode;
 import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode;
 import com.google.escapevelocity.TokenNode.NestedTokenNode;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Chars;
+import com.google.common.primitives.Ints;
 import 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
@@ -53,12 +74,20 @@
 
   /**
    * 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.
+   * This means that we almost never have to "unget" a character by reading too far. For example,
+   * after we parse an integer, {@code c} will be the first character after the integer, which is
+   * exactly the state we will be in when there are no more digits.
+   *
+   * <p>Sometimes we need to read two characters ahead, and in that case we use {@link #pushback}.
    */
   private int c;
 
+  /**
+   * A single character of pushback. If this is not negative, the {@link #next()} method will
+   * return it instead of reading a character.
+   */
+  private int pushback = -1;
+
   Parser(Reader reader, String resourceName, Template.ResourceOpener resourceOpener)
       throws IOException {
     this.reader = new LineNumberReader(reader);
@@ -127,11 +156,29 @@
    */
   private void next() throws IOException {
     if (c != EOF) {
-      c = reader.read();
+      if (pushback < 0) {
+        c = reader.read();
+      } else {
+        c = pushback;
+        pushback = -1;
+      }
     }
   }
 
   /**
+   * Saves the current character {@code c} to be read again, and sets {@code c} to the given
+   * {@code c1}. Suppose the text contains {@code xy} and we have just read {@code y}.
+   * So {@code c == 'y'}. Now if we execute {@code pushback('x')}, we will have
+   * {@code c == 'x'} and the next call to {@link #next()} will set {@code c == 'y'}. Subsequent
+   * calls to {@code next()} will continue reading from {@link #reader}. So the pushback
+   * essentially puts us back in the state we were in before we read {@code y}.
+   */
+  private void pushback(int c1) {
+    pushback = c;
+    c = c1;
+  }
+
+  /**
    * If {@code c} is a space character, keeps reading until {@code c} is a non-space character or
    * there are no more characters.
    */
@@ -174,17 +221,24 @@
   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(), "#");
+      switch (c) {
+        case '#':
+          return parseLineComment();
+        case '*':
+          return parseBlockComment();
+        case '[':
+          return parseHashSquare();
+        case '{':
+          return parseDirective();
+        default:
+          if (isAsciiLetter(c)) {
+            return parseDirective();
+          } else {
+            // For consistency with Velocity, we treat # not followed by a letter or one of the
+            // characters above as a plain character, and we treat #$foo as a literal # followed by
+            // the reference $foo.
+            return parsePlainText('#');
+          }
       }
     }
     if (c == EOF) {
@@ -200,13 +254,15 @@
     assert c == '[';
     next();
     if (c != '[') {
-      return new ConstantExpressionNode(resourceName, lineNumber(), "#[");
+      return parsePlainText(new StringBuilder("#["));
     }
+    int startLine = lineNumber();
     next();
     StringBuilder sb = new StringBuilder();
     while (true) {
       if (c == EOF) {
-        throw parseException("Unterminated #[[ - did not see matching ]]#");
+        throw new ParseException(
+            "Unterminated #[[ - did not see matching ]]#", resourceName, startLine);
       }
       if (c == '#') {
         // This might be the last character of ]]# or it might just be a random #.
@@ -458,10 +514,10 @@
   }
 
   /**
-   * Parses and discards a comment, which is {@code ##} followed by any number of characters up to
-   * and including the next newline.
+   * Parses and discards a line comment, which is {@code ##} followed by any number of characters
+   * up to and including the next newline.
    */
-  private Node parseComment() throws IOException {
+  private Node parseLineComment() throws IOException {
     int lineNumber = lineNumber();
     while (c != '\n' && c != EOF) {
       next();
@@ -471,6 +527,27 @@
   }
 
   /**
+   * Parses and discards a block comment, which is {@code #*} followed by everything up to and
+   * including the next {@code *#}.
+   */
+  private Node parseBlockComment() throws IOException {
+    assert c == '*';
+    int startLine = lineNumber();
+    int lastC = '\0';
+    next();
+    while (!(lastC == '*' && c == '#')) {
+      if (c == EOF) {
+        throw new ParseException(
+            "Unterminated #* - did not see matching *#", resourceName, startLine);
+      }
+      lastC = c;
+      next();
+    }
+    next();
+    return new CommentTokenNode(resourceName, startLine);
+  }
+
+  /**
    * Parses plain text, which is text that contains neither {@code $} nor {@code #}. The given
    * {@code firstChar} is the first character of the plain text, and {@link #c} is the second
    * (if the plain text is more than one character).
@@ -478,7 +555,10 @@
   private Node parsePlainText(int firstChar) throws IOException {
     StringBuilder sb = new StringBuilder();
     sb.appendCodePoint(firstChar);
+    return parsePlainText(sb);
+  }
 
+  private Node parsePlainText(StringBuilder sb) throws IOException {
     literal:
     while (true) {
       switch (c) {
@@ -508,7 +588,27 @@
    *
    * <p>On entry to this method, {@link #c} is the character immediately after the {@code $}.
    */
-  private ReferenceNode parseReference() throws IOException {
+  private Node parseReference() throws IOException {
+    if (c == '{') {
+      next();
+      if (!isAsciiLetter(c)) {
+        return parsePlainText(new StringBuilder("${"));
+      }
+      ReferenceNode node = parseReferenceNoBrace();
+      expect('}');
+      return node;
+    } else {
+      return parseReferenceNoBrace();
+    }
+  }
+
+  /**
+   * Same as {@link #parseReference()}, except it really must be a reference. A {@code $} in
+   * normal text doesn't start a reference if it is not followed by an identifier. But in an
+   * expression, for example in {@code #if ($x == 23)}, {@code $} must be followed by an
+   * identifier.
+   */
+  private ReferenceNode parseRequiredReference() throws IOException {
     if (c == '{') {
       next();
       ReferenceNode node = parseReferenceNoBrace();
@@ -568,6 +668,11 @@
   private ReferenceNode parseReferenceMember(ReferenceNode lhs) throws IOException {
     assert c == '.';
     next();
+    if (!isAsciiLetter(c)) {
+      // We've seen something like `$foo.!`, so it turns out it's not a member after all.
+      pushback('.');
+      return lhs;
+    }
     String id = parseId("Member");
     ReferenceNode reference;
     if (c == '(') {
@@ -670,19 +775,15 @@
    * 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;
+  private static final ImmutableListMultimap<Integer, Operator> CODE_POINT_TO_OPERATORS;
   static {
-    Map<Integer, List<Operator>> map = new HashMap<>();
+    ImmutableListMultimap.Builder<Integer, Operator> builder = ImmutableListMultimap.builder();
     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);
+        builder.put((int) operator.symbol.charAt(0), operator);
       }
     }
-    CODE_POINT_TO_OPERATORS = Collections.unmodifiableMap(map);
+    CODE_POINT_TO_OPERATORS = builder.build();
   }
 
   /**
@@ -753,17 +854,17 @@
      */
     private void nextOperator() throws IOException {
       skipSpace();
-      List<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c);
-      if (possibleOperators == null) {
+      ImmutableList<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c);
+      if (possibleOperators.isEmpty()) {
         currentOperator = Operator.STOP;
         return;
       }
-      int firstChar = c;
+      char firstChar = Chars.checkedCast(c);
       next();
       Operator operator = null;
       for (Operator possibleOperator : possibleOperators) {
         if (possibleOperator.symbol.length() == 1) {
-          assert operator == null;
+          Verify.verify(operator == null);
           operator = possibleOperator;
         } else if (possibleOperator.symbol.charAt(1) == c) {
           next();
@@ -771,7 +872,8 @@
         }
       }
       if (operator == null) {
-        throw parseException("Expected " + possibleOperators.get(0) + ", not just " + firstChar);
+        throw parseException(
+            "Expected " + Iterables.getOnlyElement(possibleOperators) + ", not just " + firstChar);
       }
       currentOperator = operator;
     }
@@ -818,7 +920,7 @@
     ExpressionNode node;
     if (c == '$') {
       next();
-      node = parseReference();
+      node = parseRequiredReference();
     } else if (c == '"') {
       node = parseStringLiteral();
     } else if (c == '-') {
@@ -869,10 +971,8 @@
       sb.appendCodePoint(c);
       next();
     }
-    int value;
-    try {
-      value = Integer.parseInt(sb.toString());
-    } catch (NumberFormatException e) {
+    Integer value = Ints.tryParse(sb.toString());
+    if (value == null) {
       throw parseException("Invalid integer: " + sb);
     }
     return new ConstantExpressionNode(resourceName, lineNumber(), value);
@@ -896,29 +996,31 @@
     return new ConstantExpressionNode(resourceName, lineNumber(), value);
   }
 
-  private static final ImmutableAsciiSet ASCII_LETTER =
-      ImmutableAsciiSet.ofRange('A', 'Z')
-          .union(ImmutableAsciiSet.ofRange('a', 'z'));
+  private static final CharMatcher ASCII_LETTER =
+      CharMatcher.inRange('A', 'Z')
+          .or(CharMatcher.inRange('a', 'z'))
+          .precomputed();
 
-  private static final ImmutableAsciiSet ASCII_DIGIT =
-      ImmutableAsciiSet.ofRange('0', '9');
+  private static final CharMatcher ASCII_DIGIT =
+      CharMatcher.inRange('0', '9')
+          .precomputed();
 
-  private static final ImmutableAsciiSet ID_CHAR =
+  private static final CharMatcher ID_CHAR =
       ASCII_LETTER
-          .union(ASCII_DIGIT)
-          .union(ImmutableAsciiSet.of('-'))
-          .union(ImmutableAsciiSet.of('_'));
+          .or(ASCII_DIGIT)
+          .or(CharMatcher.anyOf("-_"))
+          .precomputed();
 
   private static boolean isAsciiLetter(int c) {
-    return ASCII_LETTER.contains(c);
+    return (char) c == c && ASCII_LETTER.matches((char) c);
   }
 
   private static boolean isAsciiDigit(int c) {
-    return ASCII_DIGIT.contains(c);
+    return (char) c == c && ASCII_DIGIT.matches((char) c);
   }
 
   private static boolean isIdChar(int c) {
-    return ID_CHAR.contains(c);
+    return (char) c == c && ID_CHAR.matches((char) c);
   }
 
   /**
diff --git a/src/main/java/com/google/escapevelocity/ReferenceNode.java b/src/main/java/com/google/escapevelocity/ReferenceNode.java
index 865d02a..6302561 100644
--- a/src/main/java/com/google/escapevelocity/ReferenceNode.java
+++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java
@@ -11,14 +11,35 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
 package com.google.escapevelocity;
 
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Primitives;
 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;
 
@@ -227,13 +248,11 @@
           throw evaluationException(
               "Parameters for method " + id + " have wrong types: " + argValues);
         case 1:
-          return invokeMethod(compatibleMethods.get(0), lhsValue, argValues);
+          return invokeMethod(Iterables.getOnlyElement(compatibleMethods), 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());
+          throw evaluationException(
+              "Ambiguous method invocation, could be one of:\n  "
+              + Joiner.on("\n  ").join(compatibleMethods));
       }
     }
 
@@ -258,29 +277,11 @@
       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) {
+      if (value == null || !Primitives.isWrapperType(value.getClass())) {
         return false;
       }
-      Class<?> unboxed = BOXED_TO_UNBOXED.get(value.getClass());
-      if (unboxed == null) {
-        return false;
-      }
-      return primitiveTypeIsAssignmentCompatible(primitive, unboxed);
+      return primitiveTypeIsAssignmentCompatible(primitive, Primitives.unwrap(value.getClass()));
     }
 
     private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.<Class<?>>of(
diff --git a/src/main/java/com/google/escapevelocity/Reparser.java b/src/main/java/com/google/escapevelocity/Reparser.java
index 6235bc4..8e86180 100644
--- a/src/main/java/com/google/escapevelocity/Reparser.java
+++ b/src/main/java/com/google/escapevelocity/Reparser.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
@@ -29,6 +48,10 @@
 import com.google.escapevelocity.TokenNode.IfTokenNode;
 import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode;
 import com.google.escapevelocity.TokenNode.NestedTokenNode;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
@@ -67,7 +90,7 @@
   private final Map<String, Macro> macros;
 
   Reparser(ImmutableList<Node> nodes) {
-    this(nodes, new TreeMap<String, Macro>());
+    this(nodes, new TreeMap<>());
   }
 
   private Reparser(ImmutableList<Node> nodes, Map<String, Macro> macros) {
@@ -94,7 +117,7 @@
    * ({@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);
+    assert Iterables.getLast(nodes) instanceof EofNode;
     // Since the last node is EofNode, the i + 1 and i + 2 accesses below are safe.
     ImmutableList.Builder<Node> newNodes = ImmutableList.builder();
     for (int i = 0; i < nodes.size(); i++) {
@@ -120,18 +143,7 @@
   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 constant instanceof String && CharMatcher.whitespace().matchesAllOf((String) constant);
     }
     return false;
   }
diff --git a/src/main/java/com/google/escapevelocity/Template.java b/src/main/java/com/google/escapevelocity/Template.java
index 646c42b..3613dbe 100644
--- a/src/main/java/com/google/escapevelocity/Template.java
+++ b/src/main/java/com/google/escapevelocity/Template.java
@@ -11,6 +11,25 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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;
@@ -60,15 +79,12 @@
    * 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);
-        }
+  public static Template parseFrom(Reader reader) throws IOException {
+    ResourceOpener resourceOpener = resourceName -> {
+      if (resourceName == null) {
+        return reader;
+      } else {
+        throw new IOException("No ResourceOpener has been configured to read " + resourceName);
       }
     };
     try {
diff --git a/src/main/java/com/google/escapevelocity/TokenNode.java b/src/main/java/com/google/escapevelocity/TokenNode.java
index 1e92109..a7226fc 100644
--- a/src/main/java/com/google/escapevelocity/TokenNode.java
+++ b/src/main/java/com/google/escapevelocity/TokenNode.java
@@ -11,8 +11,28 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
 package com.google.escapevelocity;
 
+import com.google.common.collect.ImmutableList;
 import java.util.List;
 
 /**
@@ -164,4 +184,3 @@
     }
   }
 }
-
diff --git a/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java
index 660c237..c71eb1a 100644
--- a/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java
+++ b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java
@@ -15,11 +15,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.escapevelocity.ReferenceNode.MethodReferenceNode;
 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;
diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java
index bd769d6..0437734 100644
--- a/src/test/java/com/google/escapevelocity/TemplateTest.java
+++ b/src/test/java/com/google/escapevelocity/TemplateTest.java
@@ -14,24 +14,32 @@
 package com.google.escapevelocity;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
-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.ByteArrayInputStream;
+import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.StringReader;
 import java.io.StringWriter;
+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.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;
@@ -52,18 +60,21 @@
   private RuntimeInstance velocityRuntimeInstance;
 
   @Before
-  public void setUp() {
-    velocityRuntimeInstance = new RuntimeInstance();
+  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.
-    velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
-    velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS,
-        new NullLogChute());
+    runtimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
 
     // Disable any logging that Velocity might otherwise see fit to do.
-    velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute());
-
-    velocityRuntimeInstance.init();
+    runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, new NullLogChute());
+    runtimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute());
+    return runtimeInstance;
   }
 
   private void compare(String template) {
@@ -71,7 +82,7 @@
   }
 
   private void compare(String template, Map<String, ?> vars) {
-    compare(template, Suppliers.ofInstance(vars));
+    compare(template, () -> vars);
   }
 
   /**
@@ -89,11 +100,13 @@
     try {
       escapeVelocityRendered =
           Template.parseFrom(new StringReader(template)).evaluate(escapeVelocityVars);
-    } catch (IOException e) {
-      throw new AssertionError(e);
+    } catch (Exception e) {
+      throw new AssertionError(
+          "EscapeVelocity failed, but Velocity succeeded and returned: <" + velocityRendered + ">",
+          e);
     }
-    String failure = "from velocity: <" + velocityRendered + ">\n"
-        + "from escape velocity: <" + escapeVelocityRendered + ">\n";
+    String failure = "from Velocity: <" + velocityRendered + ">\n"
+        + "from EscapeVelocity: <" + escapeVelocityRendered + ">\n";
     expect.withMessage(failure).that(escapeVelocityRendered).isEqualTo(velocityRendered);
   }
 
@@ -124,17 +137,58 @@
   }
 
   @Test
-  public void comment() {
+  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"));
   }
@@ -150,6 +204,18 @@
   }
 
   @Test
+  public void substituteNotPropertyId() {
+    compare("$foo.!", ImmutableMap.of("foo", false));
+  }
+
+  /* TODO(emcmanus): make this work.
+  @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()));
   }
@@ -206,6 +272,10 @@
     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 + "]";
@@ -319,7 +389,7 @@
 
   /**
    * Tests the surprising definition of equality mentioned in
-   * {@link ExpressionNode.EqualsExpressionNode}.
+   * {@link ExpressionNode.BinaryExpressionNode}.
    */
   @Test
   public void funkyEquals() {
@@ -452,6 +522,7 @@
     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
@@ -633,6 +704,14 @@
   }
 
   @Test
+  public void badBraceReference() throws IOException {
+    String template = "line 1\nline 2\nbar${foo.!}baz";
+    thrown.expect(ParseException.class);
+    thrown.expectMessage("Expected }, on line 3, at text starting: .!}baz");
+    Template.parseFrom(new StringReader(template));
+  }
+
+  @Test
   public void undefinedMacro() throws IOException {
     String template = "#oops()";
     thrown.expect(ParseException.class);
@@ -650,4 +729,117 @@
     Template.parseFrom(new StringReader(template));
   }
 
+  @Test
+  public void unclosedBlockQuote() throws IOException {
+    String template = "foo\nbar #[[\nblah\nblah";
+    thrown.expect(ParseException.class);
+    thrown.expectMessage("Unterminated #[[ - did not see matching ]]#, on line 2");
+    Template.parseFrom(new StringReader(template));
+  }
+
+  @Test
+  public void unclosedBlockComment() throws IOException {
+    String template = "foo\nbar #*\nblah\nblah";
+    thrown.expect(ParseException.class);
+    thrown.expectMessage("Unterminated #* - did not see matching *#, on line 2");
+    Template.parseFrom(new StringReader(template));
+  }
+
+  /**
+   * 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");
+    }
+  }
 }