aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorColin Cross <ccross@android.com>2020-06-19 06:42:10 +0000
committerAutomerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>2020-06-19 06:42:10 +0000
commitbaaddbfbc5c804da71c6f41f8ab92bba7f340327 (patch)
tree2f8cc743bd7babbb6fa85cd2b98e4e5e46c847f8
parent4e70048b1a58f2f479f6d9179eecb395edb95522 (diff)
parent6ddc2e77d3a9e66174e0344e8f17338a511d4f03 (diff)
downloadescapevelocity-baaddbfbc5c804da71c6f41f8ab92bba7f340327.tar.gz
Merge tag 'escapevelocity-0.9.1' into master am: 43799cbf40 am: 6b276f51ce am: f29fb6ed5e am: cc05f1b8c0 am: 6ddc2e77d3
Original change: https://android-review.googlesource.com/c/platform/external/escapevelocity/+/1343296 Change-Id: I98942f8946a7975cef65e6df530e22321112bb8f
-rw-r--r--CONTRIBUTING.md23
-rw-r--r--LICENSE202
-rw-r--r--NOTICE6
-rw-r--r--README.md379
-rw-r--r--pom.xml107
-rw-r--r--src/main/java/com/google/escapevelocity/ConstantExpressionNode.java43
-rw-r--r--src/main/java/com/google/escapevelocity/DirectiveNode.java226
-rw-r--r--src/main/java/com/google/escapevelocity/EvaluationContext.java85
-rw-r--r--src/main/java/com/google/escapevelocity/EvaluationException.java34
-rw-r--r--src/main/java/com/google/escapevelocity/ExpressionNode.java188
-rw-r--r--src/main/java/com/google/escapevelocity/Macro.java141
-rw-r--r--src/main/java/com/google/escapevelocity/MethodFinder.java172
-rw-r--r--src/main/java/com/google/escapevelocity/Node.java92
-rw-r--r--src/main/java/com/google/escapevelocity/ParseException.java41
-rw-r--r--src/main/java/com/google/escapevelocity/Parser.java1094
-rw-r--r--src/main/java/com/google/escapevelocity/ReferenceNode.java332
-rw-r--r--src/main/java/com/google/escapevelocity/Reparser.java283
-rw-r--r--src/main/java/com/google/escapevelocity/Template.java133
-rw-r--r--src/main/java/com/google/escapevelocity/TokenNode.java169
-rw-r--r--src/test/java/com/google/escapevelocity/MethodFinderTest.java92
-rw-r--r--src/test/java/com/google/escapevelocity/ReferenceNodeTest.java94
-rw-r--r--src/test/java/com/google/escapevelocity/TemplateTest.java954
22 files changed, 4890 insertions, 0 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ae319c7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,23 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement. You (or your employer) retain the copyright to your contribution,
+this simply gives us permission to use and redistribute your contributions as
+part of the project. Head over to <https://cla.developers.google.com/> to see
+your current agreements on file or to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..1527b34
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,6 @@
+Apache Velocity
+
+Copyright (C) 2000-2007 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e716ecf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,379 @@
+# 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 an official 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/pom.xml b/pom.xml
new file mode 100644
index 0000000..7f0e0a5
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.sonatype.oss</groupId>
+ <artifactId>oss-parent</artifactId>
+ <version>9</version>
+ </parent>
+
+ <groupId>com.google.escapevelocity</groupId>
+ <artifactId>escapevelocity</artifactId>
+ <version>0.9.1</version>
+ <name>EscapeVelocity</name>
+ <description>
+ A reimplementation of a subset of the Apache Velocity templating system.
+ </description>
+
+ <licenses>
+ <license>
+ <name>Apache License, Version 2.0</name>
+ <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+ <distribution>repo</distribution>
+ </license>
+ </licenses>
+
+ <scm>
+ <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>
+ </dependency>
+ <!-- test dependencies -->
+ <dependency>
+ <groupId>org.apache.velocity</groupId>
+ <artifactId>velocity</artifactId>
+ <version>1.7</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava-testlib</artifactId>
+ <version>23.5-jre</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.12</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.google.truth</groupId>
+ <artifactId>truth</artifactId>
+ <version>0.44</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>3.7.0</version>
+ <configuration>
+ <source>1.8</source>
+ <target>1.8</target>
+ <compilerArgument>-Xlint:all</compilerArgument>
+ <showWarnings>true</showWarnings>
+ <showDeprecation>true</showDeprecation>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <version>3.0.2</version>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-invoker-plugin</artifactId>
+ <version>3.0.1</version>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java
new file mode 100644
index 0000000..50fc9bc
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+/**
+ * A node in the parse tree representing a constant value. Evaluating the node yields the constant
+ * value. Instances of this class are used both in expressions, like the {@code 23} in
+ * {@code #set ($x = 23)}, and for literal text in templates. In the template...
+ * <pre>{@code
+ * abc#{if}($x == 5)def#{end}xyz
+ * }</pre>
+ * ...each of the strings {@code abc}, {@code def}, {@code xyz} is represented by an instance of
+ * this class that {@linkplain #evaluate evaluates} to that string, and the value {@code 5} is
+ * represented by an instance of this class that evaluates to the integer 5.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+class ConstantExpressionNode extends ExpressionNode {
+ private final Object value;
+
+ ConstantExpressionNode(String resourceName, int lineNumber, Object value) {
+ super(resourceName, lineNumber);
+ this.value = value;
+ }
+
+ @Override
+ Object evaluate(EvaluationContext context) {
+ return value;
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/DirectiveNode.java b/src/main/java/com/google/escapevelocity/DirectiveNode.java
new file mode 100644
index 0000000..fd0cd22
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/DirectiveNode.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * A node in the parse tree that is a directive such as {@code #set ($x = $y)}
+ * or {@code #if ($x) y #end}.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+abstract class DirectiveNode extends Node {
+ DirectiveNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ /**
+ * A node in the parse tree representing a {@code #set} construct. Evaluating
+ * {@code #set ($x = 23)} will set {@code $x} to the value 23. It does not in itself produce
+ * any text in the output.
+ *
+ * <p>Velocity supports setting values within arrays or collections, with for example
+ * {@code $set ($x[$i] = $y)}. That is not currently supported here.
+ */
+ static class SetNode extends DirectiveNode {
+ private final String var;
+ private final Node expression;
+
+ SetNode(String var, Node expression) {
+ super(expression.resourceName, expression.lineNumber);
+ this.var = var;
+ this.expression = expression;
+ }
+
+ @Override
+ Object evaluate(EvaluationContext context) {
+ context.setVar(var, expression.evaluate(context));
+ return "";
+ }
+ }
+
+ /**
+ * A node in the parse tree representing an {@code #if} construct. All instances of this class
+ * have a <i>true</i> subtree and a <i>false</i> subtree. For a plain {@code #if (cond) body
+ * #end}, the false subtree will be empty. For {@code #if (cond1) body1 #elseif (cond2) body2
+ * #else body3 #end}, the false subtree will contain a nested {@code IfNode}, as if {@code #else
+ * #if} had been used instead of {@code #elseif}.
+ */
+ static class IfNode extends DirectiveNode {
+ private final ExpressionNode condition;
+ private final Node truePart;
+ private final Node falsePart;
+
+ IfNode(
+ String resourceName,
+ int lineNumber,
+ ExpressionNode condition,
+ Node trueNode,
+ Node falseNode) {
+ super(resourceName, lineNumber);
+ this.condition = condition;
+ this.truePart = trueNode;
+ this.falsePart = falseNode;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ Node branch = condition.isDefinedAndTrue(context) ? truePart : falsePart;
+ return branch.evaluate(context);
+ }
+ }
+
+ /**
+ * A node in the parse tree representing a {@code #foreach} construct. While evaluating
+ * {@code #foreach ($x in $things)}, {$code $x} will be set to each element of {@code $things} in
+ * turn. Once the loop completes, {@code $x} will go back to whatever value it had before, which
+ * might be undefined. During loop execution, the variable {@code $foreach} is also defined.
+ * Velocity defines a number of properties in this variable, but here we only support
+ * {@code $foreach.hasNext}.
+ */
+ static class ForEachNode extends DirectiveNode {
+ private final String var;
+ private final ExpressionNode collection;
+ private final Node body;
+
+ ForEachNode(String resourceName, int lineNumber, String var, ExpressionNode in, Node body) {
+ super(resourceName, lineNumber);
+ this.var = var;
+ this.collection = in;
+ this.body = body;
+ }
+
+ @Override
+ Object evaluate(EvaluationContext context) {
+ Object collectionValue = collection.evaluate(context);
+ Iterable<?> iterable;
+ if (collectionValue instanceof Iterable<?>) {
+ iterable = (Iterable<?>) collectionValue;
+ } else if (collectionValue instanceof Object[]) {
+ iterable = Arrays.asList((Object[]) collectionValue);
+ } else if (collectionValue instanceof Map<?, ?>) {
+ iterable = ((Map<?, ?>) collectionValue).values();
+ } else {
+ throw evaluationException("Not iterable: " + collectionValue);
+ }
+ Runnable undo = context.setVar(var, null);
+ StringBuilder sb = new StringBuilder();
+ CountingIterator it = new CountingIterator(iterable.iterator());
+ Runnable undoForEach = context.setVar("foreach", new ForEachVar(it));
+ while (it.hasNext()) {
+ context.setVar(var, it.next());
+ sb.append(body.evaluate(context));
+ }
+ undoForEach.run();
+ undo.run();
+ return sb.toString();
+ }
+
+ private static class CountingIterator implements Iterator<Object> {
+ private final Iterator<?> iterator;
+ private int index = -1;
+
+ CountingIterator(Iterator<?> iterator) {
+ this.iterator = iterator;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ public Object next() {
+ Object next = iterator.next();
+ index++;
+ return next;
+ }
+
+ int index() {
+ return index;
+ }
+ }
+
+ /**
+ * This class is the type of the variable {@code $foreach} that is defined within
+ * {@code #foreach} loops. Its {@link #getHasNext()} method means that we can write
+ * {@code #if ($foreach.hasNext)} and likewise for {@link #getIndex()}.
+ */
+ private static class ForEachVar {
+ private final CountingIterator iterator;
+
+ ForEachVar(CountingIterator iterator) {
+ this.iterator = iterator;
+ }
+
+ public boolean getHasNext() {
+ return iterator.hasNext();
+ }
+
+ public int getIndex() {
+ return iterator.index();
+ }
+ }
+ }
+
+ /**
+ * A node in the parse tree representing a macro call. If the template contains a definition like
+ * {@code #macro (mymacro $x $y) ... #end}, then a call of that macro looks like
+ * {@code #mymacro (xvalue yvalue)}. The call is represented by an instance of this class. The
+ * definition itself does not appear in the parse tree.
+ *
+ * <p>Evaluating a macro involves temporarily setting the parameter variables ({@code $x $y} in
+ * the example) to thunks representing the argument expressions, evaluating the macro body, and
+ * restoring any previous values that the parameter variables had.
+ */
+ static class MacroCallNode extends DirectiveNode {
+ private final String name;
+ private final ImmutableList<Node> thunks;
+ private Macro macro;
+
+ MacroCallNode(
+ String resourceName,
+ int lineNumber,
+ String name,
+ ImmutableList<Node> argumentNodes) {
+ super(resourceName, lineNumber);
+ this.name = name;
+ this.thunks = argumentNodes;
+ }
+
+ String name() {
+ return name;
+ }
+
+ int argumentCount() {
+ return thunks.size();
+ }
+
+ void setMacro(Macro macro) {
+ this.macro = macro;
+ }
+
+ @Override
+ Object evaluate(EvaluationContext context) {
+ Verify.verifyNotNull(macro, "Macro #%s should have been linked", name);
+ return macro.evaluate(context, thunks);
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/EvaluationContext.java b/src/main/java/com/google/escapevelocity/EvaluationContext.java
new file mode 100644
index 0000000..d40b717
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/EvaluationContext.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * The context of a template evaluation. This consists of the template variables and the template
+ * macros. The template variables start with the values supplied by the evaluation call, and can
+ * be changed by {@code #set} directives and during the execution of {@code #foreach} and macro
+ * calls. The macros are extracted from the template during parsing and never change thereafter.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+interface EvaluationContext {
+ Object getVar(String var);
+
+ boolean varIsDefined(String var);
+
+ /**
+ * Sets the given variable to the given value.
+ *
+ * @return a Runnable that will restore the variable to the value it had before. If the variable
+ * was undefined before this method was executed, the Runnable will make it undefined again.
+ * This allows us to restore the state of {@code $x} after {@code #foreach ($x in ...)}.
+ */
+ Runnable setVar(final String var, Object value);
+
+ /** See {@link MethodFinder#publicMethodsWithName}. */
+ ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name);
+
+ class PlainEvaluationContext implements EvaluationContext {
+ private final Map<String, Object> vars;
+ private final MethodFinder methodFinder;
+
+ PlainEvaluationContext(Map<String, ?> vars, MethodFinder methodFinder) {
+ this.vars = new TreeMap<>(vars);
+ this.methodFinder = methodFinder;
+ }
+
+ @Override
+ public Object getVar(String var) {
+ return vars.get(var);
+ }
+
+ @Override
+ public boolean varIsDefined(String var) {
+ return vars.containsKey(var);
+ }
+
+ @Override
+ public Runnable setVar(final String var, Object value) {
+ Runnable undo;
+ if (vars.containsKey(var)) {
+ final Object oldValue = vars.get(var);
+ undo = () -> vars.put(var, oldValue);
+ } else {
+ undo = () -> vars.remove(var);
+ }
+ vars.put(var, value);
+ return undo;
+ }
+
+ @Override
+ public ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) {
+ return methodFinder.publicMethodsWithName(startClass, name);
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/EvaluationException.java b/src/main/java/com/google/escapevelocity/EvaluationException.java
new file mode 100644
index 0000000..a1f25a4
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/EvaluationException.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+/**
+ * An exception that occurred while evaluating a template, such as an undefined variable reference
+ * or a division by zero.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+public class EvaluationException extends RuntimeException {
+ private static final long serialVersionUID = 1;
+
+ EvaluationException(String message) {
+ super(message);
+ }
+
+ EvaluationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/ExpressionNode.java b/src/main/java/com/google/escapevelocity/ExpressionNode.java
new file mode 100644
index 0000000..281e998
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/ExpressionNode.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.escapevelocity.Parser.Operator;
+
+/**
+ * A node in the parse tree representing an expression. Expressions appear inside directives,
+ * specifically {@code #set}, {@code #if}, {@code #foreach}, and macro calls. Expressions can
+ * also appear inside indices in references, like {@code $x[$i]}.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+abstract class ExpressionNode extends Node {
+ ExpressionNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ /**
+ * True if evaluating this expression yields a value that is considered true by Velocity's
+ * <a href="http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html#Conditionals">
+ * rules</a>. A value is false if it is null or equal to Boolean.FALSE.
+ * Every other value is true.
+ *
+ * <p>Note that the text at the similar link
+ * <a href="http://velocity.apache.org/engine/devel/user-guide.html#Conditionals">here</a>
+ * states that empty collections and empty strings are also considered false, but that is not
+ * true.
+ */
+ boolean isTrue(EvaluationContext context) {
+ Object value = evaluate(context);
+ if (value instanceof Boolean) {
+ return (Boolean) value;
+ } else {
+ return value != null;
+ }
+ }
+
+ /**
+ * True if this is a defined value and it evaluates to true. This is the same as {@link #isTrue}
+ * except that it is allowed for this to be undefined variable, in which it evaluates to false.
+ * The method is overridden for plain references so that undefined is the same as false.
+ * The reason is to support Velocity's idiom {@code #if ($var)}, where it is not an error
+ * if {@code $var} is undefined.
+ */
+ boolean isDefinedAndTrue(EvaluationContext context) {
+ return isTrue(context);
+ }
+
+ /**
+ * The integer result of evaluating this expression.
+ *
+ * @throws EvaluationException if evaluating the expression produces an exception, or if it
+ * yields a value that is not an integer.
+ */
+ int intValue(EvaluationContext context) {
+ Object value = evaluate(context);
+ if (!(value instanceof Integer)) {
+ throw evaluationException("Arithemtic is only available on integers, not " + show(value));
+ }
+ return (Integer) value;
+ }
+
+ /**
+ * Returns a string representing the given value, for use in error messages. The string
+ * includes both the value's {@code toString()} and its type.
+ */
+ private static String show(Object value) {
+ if (value == null) {
+ return "null";
+ } else {
+ return value + " (a " + value.getClass().getName() + ")";
+ }
+ }
+
+ /**
+ * Represents all binary expressions. In {@code #set ($a = $b + $c)}, this will be the type
+ * of the node representing {@code $b + $c}.
+ */
+ static class BinaryExpressionNode extends ExpressionNode {
+ final ExpressionNode lhs;
+ final Operator op;
+ final ExpressionNode rhs;
+
+ BinaryExpressionNode(ExpressionNode lhs, Operator op, ExpressionNode rhs) {
+ super(lhs.resourceName, lhs.lineNumber);
+ this.lhs = lhs;
+ this.op = op;
+ this.rhs = rhs;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ switch (op) {
+ case OR:
+ return lhs.isTrue(context) || rhs.isTrue(context);
+ case AND:
+ return lhs.isTrue(context) && rhs.isTrue(context);
+ case EQUAL:
+ return equal(context);
+ case NOT_EQUAL:
+ return !equal(context);
+ default: // fall out
+ }
+ int lhsInt = lhs.intValue(context);
+ int rhsInt = rhs.intValue(context);
+ switch (op) {
+ case LESS:
+ return lhsInt < rhsInt;
+ case LESS_OR_EQUAL:
+ return lhsInt <= rhsInt;
+ case GREATER:
+ return lhsInt > rhsInt;
+ case GREATER_OR_EQUAL:
+ return lhsInt >= rhsInt;
+ case PLUS:
+ return lhsInt + rhsInt;
+ case MINUS:
+ return lhsInt - rhsInt;
+ case TIMES:
+ return lhsInt * rhsInt;
+ case DIVIDE:
+ return lhsInt / rhsInt;
+ case REMAINDER:
+ return lhsInt % rhsInt;
+ default:
+ throw new AssertionError(op);
+ }
+ }
+
+ /**
+ * Returns true if {@code lhs} and {@code rhs} are equal according to Velocity.
+ *
+ * <p>Velocity's <a
+ * href="http://velocity.apache.org/engine/releases/velocity-1.7/vtl-reference-guide.html#aifelseifelse_-_Output_conditional_on_truth_of_statements">definition
+ * of equality</a> differs depending on whether the objects being compared are of the same
+ * class. If so, equality comes from {@code Object.equals} as you would expect. But if they
+ * are not of the same class, they are considered equal if their {@code toString()} values are
+ * equal. This means that integer 123 equals long 123L and also string {@code "123"}. It also
+ * means that equality isn't always transitive. For example, two StringBuilder objects each
+ * containing {@code "123"} will not compare equal, even though the string {@code "123"}
+ * compares equal to each of them.
+ */
+ private boolean equal(EvaluationContext context) {
+ Object lhsValue = lhs.evaluate(context);
+ Object rhsValue = rhs.evaluate(context);
+ if (lhsValue == rhsValue) {
+ return true;
+ }
+ if (lhsValue == null || rhsValue == null) {
+ return false;
+ }
+ if (lhsValue.getClass().equals(rhsValue.getClass())) {
+ return lhsValue.equals(rhsValue);
+ }
+ // Funky equals behaviour specified by Velocity.
+ return lhsValue.toString().equals(rhsValue.toString());
+ }
+ }
+
+ /**
+ * A node in the parse tree representing an expression like {@code !$a}.
+ */
+ static class NotExpressionNode extends ExpressionNode {
+ private final ExpressionNode expr;
+
+ NotExpressionNode(ExpressionNode expr) {
+ super(expr.resourceName, expr.lineNumber);
+ this.expr = expr;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ return !expr.isTrue(context);
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/Macro.java b/src/main/java/com/google/escapevelocity/Macro.java
new file mode 100644
index 0000000..afa7bf0
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/Macro.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A macro definition. Macros appear in templates using the syntax {@code #macro (m $x $y) ... #end}
+ * and each one produces an instance of this class. Evaluating a macro involves setting the
+ * parameters (here {$x $y)} and evaluating the macro body. Macro arguments are call-by-name, which
+ * means that we need to set each parameter variable to the node in the parse tree that corresponds
+ * to it, and arrange for that node to be evaluated when the variable is actually referenced.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+class Macro {
+ private final int definitionLineNumber;
+ private final String name;
+ private final ImmutableList<String> parameterNames;
+ private final Node body;
+
+ Macro(int definitionLineNumber, String name, List<String> parameterNames, Node body) {
+ this.definitionLineNumber = definitionLineNumber;
+ this.name = name;
+ this.parameterNames = ImmutableList.copyOf(parameterNames);
+ this.body = body;
+ }
+
+ String name() {
+ return name;
+ }
+
+ int parameterCount() {
+ return parameterNames.size();
+ }
+
+ Object evaluate(EvaluationContext context, List<Node> thunks) {
+ try {
+ Verify.verify(thunks.size() == parameterNames.size(), "Argument mistmatch for %s", name);
+ Map<String, Node> parameterThunks = new LinkedHashMap<>();
+ for (int i = 0; i < parameterNames.size(); i++) {
+ parameterThunks.put(parameterNames.get(i), thunks.get(i));
+ }
+ EvaluationContext newContext = new MacroEvaluationContext(parameterThunks, context);
+ return body.evaluate(newContext);
+ } catch (EvaluationException e) {
+ EvaluationException newException = new EvaluationException(
+ "In macro #" + name + " defined on line " + definitionLineNumber + ": " + e.getMessage());
+ newException.setStackTrace(e.getStackTrace());
+ throw e;
+ }
+ }
+
+ /**
+ * The context for evaluation within macros. This wraps an existing {@code EvaluationContext}
+ * but intercepts reads of the macro's parameters so that they result in a call-by-name evaluation
+ * of whatever was passed as the parameter. For example, if you write...
+ * <pre>{@code
+ * #macro (mymacro $x)
+ * $x $x
+ * #end
+ * #mymacro($foo.bar(23))
+ * }</pre>
+ * ...then the {@code #mymacro} call will result in {@code $foo.bar(23)} being evaluated twice,
+ * once for each time {@code $x} appears. The way this works is that {@code $x} is a <i>thunk</i>.
+ * Historically a thunk is a piece of code to evaluate an expression in the context where it
+ * occurs, for call-by-name procedures as in Algol 60. Here, it is not exactly a piece of code,
+ * but it has the same responsibility.
+ */
+ static class MacroEvaluationContext implements EvaluationContext {
+ private final Map<String, Node> parameterThunks;
+ private final EvaluationContext originalEvaluationContext;
+
+ MacroEvaluationContext(
+ Map<String, Node> parameterThunks, EvaluationContext originalEvaluationContext) {
+ this.parameterThunks = parameterThunks;
+ this.originalEvaluationContext = originalEvaluationContext;
+ }
+
+ @Override
+ public Object getVar(String var) {
+ Node thunk = parameterThunks.get(var);
+ if (thunk == null) {
+ return originalEvaluationContext.getVar(var);
+ } else {
+ // Evaluate the thunk in the context where it appeared, not in this context. Otherwise
+ // if you pass $x to a parameter called $x you would get an infinite recursion. Likewise
+ // if you had #macro(mymacro $x $y) and a call #mymacro($y 23), you would expect that $x
+ // would expand to whatever $y meant at the call site, rather than to the value of the $y
+ // parameter.
+ return thunk.evaluate(originalEvaluationContext);
+ }
+ }
+
+ @Override
+ public boolean varIsDefined(String var) {
+ return parameterThunks.containsKey(var) || originalEvaluationContext.varIsDefined(var);
+ }
+
+ @Override
+ public Runnable setVar(final String var, Object value) {
+ // Copy the behaviour that #set will shadow a macro parameter, even though the Velocity peeps
+ // seem to agree that that is not good.
+ final Node thunk = parameterThunks.get(var);
+ if (thunk == null) {
+ return originalEvaluationContext.setVar(var, value);
+ } else {
+ parameterThunks.remove(var);
+ final Runnable originalUndo = originalEvaluationContext.setVar(var, value);
+ return () -> {
+ originalUndo.run();
+ parameterThunks.put(var, thunk);
+ };
+ }
+ }
+
+ @Override
+ public ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) {
+ return originalEvaluationContext.publicMethodsWithName(startClass, name);
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/MethodFinder.java b/src/main/java/com/google/escapevelocity/MethodFinder.java
new file mode 100644
index 0000000..f8f91f5
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/MethodFinder.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2019 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import static com.google.common.reflect.Reflection.getPackageName;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Finds public methods in a class. For each one, it determines the public class or interface in
+ * which it is declared. This avoids a problem with reflection, where we get an exception if we call
+ * a {@code Method} in a non-public class, even if the {@code Method} is public and if there is a
+ * public ancestor class or interface that declares it. We need to use the {@code Method} from the
+ * public ancestor.
+ *
+ * <p>Because looking for these methods is relatively expensive, an instance of this class will keep
+ * a cache of methods it previously discovered.
+ */
+class MethodFinder {
+
+ /**
+ * For a given class and name, returns all public methods of that name in the class, as previously
+ * determined by {@link #publicMethodsWithName}. The set of methods for a given class and name is
+ * saved the first time it is searched for, and returned directly thereafter. It may be empty.
+ *
+ * <p>Currently we add the entry for any given (class, name) pair on demand. An alternative would
+ * be to add all the methods for a given class at once. With the current scheme, we may end up
+ * calling {@link Class#getMethods()} several times for the same class, if methods of the
+ * different names are called at different times. With an all-at-once scheme, we might end up
+ * computing and storing information about a bunch of methods that will never be called. Because
+ * the profiling that led to the creation of this class revealed that {@link #visibleMethods} in
+ * particular is quite expensive, it's probably best to avoid calling it unnecessarily.
+ */
+ private final Table<Class<?>, String, ImmutableSet<Method>> methodCache = HashBasedTable.create();
+
+ /**
+ * Returns the set of public methods with the given name in the given class. Here, "public
+ * methods" means public methods in public classes or interfaces. If {@code startClass} is not
+ * itself public, its methods are effectively not public either, but inherited methods may still
+ * appear in the returned set, with the {@code Method} objects belonging to public ancestors. More
+ * than one ancestor may define an appropriate method, but it doesn't matter because invoking any
+ * of those {@code Method} objects will have the same effect.
+ */
+ synchronized ImmutableSet<Method> publicMethodsWithName(Class<?> startClass, String name) {
+ ImmutableSet<Method> cachedMethods = methodCache.get(startClass, name);
+ if (cachedMethods == null) {
+ cachedMethods = uncachedPublicMethodsWithName(startClass, name);
+ methodCache.put(startClass, name, cachedMethods);
+ }
+ return cachedMethods;
+ }
+
+ private ImmutableSet<Method> uncachedPublicMethodsWithName(Class<?> startClass, String name) {
+ // Class.getMethods() only returns public methods, so no need to filter explicitly for public.
+ Set<Method> methods =
+ Arrays.stream(startClass.getMethods())
+ .filter(m -> m.getName().equals(name))
+ .collect(toSet());
+ if (!classIsPublic(startClass)) {
+ methods =
+ methods.stream()
+ .map(m -> visibleMethod(m, startClass))
+ .filter(Objects::nonNull)
+ .collect(toSet());
+ // It would be a bit simpler to use ImmutableSet.toImmutableSet() here, but there've been
+ // problems in the past with versions of Guava that don't have that method.
+ }
+ return ImmutableSet.copyOf(methods);
+ }
+
+ private static final String THIS_PACKAGE = getPackageName(Node.class) + ".";
+
+ /**
+ * Returns a Method with the same name and parameter types as the given one, but that is in a
+ * public class or interface. This might be the given method, or it might be a method in a
+ * superclass or superinterface.
+ *
+ * @return a public method in a public class or interface, or null if none was found.
+ */
+ static Method visibleMethod(Method method, Class<?> in) {
+ if (in == null) {
+ return null;
+ }
+ Method methodInClass;
+ try {
+ methodInClass = in.getMethod(method.getName(), method.getParameterTypes());
+ } catch (NoSuchMethodException e) {
+ return null;
+ }
+ if (classIsPublic(in) || in.getName().startsWith(THIS_PACKAGE)) {
+ // The second disjunct is a hack to allow us to use the public methods of $foreach without
+ // having to make the ForEachVar class public. We can invoke those methods from the same
+ // package since ForEachVar is package-protected.
+ return methodInClass;
+ }
+ Method methodInSuperclass = visibleMethod(method, in.getSuperclass());
+ if (methodInSuperclass != null) {
+ return methodInSuperclass;
+ }
+ for (Class<?> superinterface : in.getInterfaces()) {
+ Method methodInSuperinterface = visibleMethod(method, superinterface);
+ if (methodInSuperinterface != null) {
+ return methodInSuperinterface;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns whether the given class is public as seen from this class. Prior to Java 9, a class was
+ * either public or not public. But with the introduction of modules in Java 9, a class can be
+ * marked public and yet not be visible, if it is not exported from the module it appears in. So,
+ * on Java 9, we perform an additional check on class {@code c}, which is effectively {@code
+ * c.getModule().isExported(c.getPackageName())}. We use reflection so that the code can compile
+ * on earlier Java versions.
+ */
+ private static boolean classIsPublic(Class<?> c) {
+ return Modifier.isPublic(c.getModifiers()) && classIsExported(c);
+ }
+
+ private static boolean classIsExported(Class<?> c) {
+ if (CLASS_GET_MODULE_METHOD == null) {
+ return true; // There are no modules, so all classes are exported.
+ }
+ try {
+ String pkg = getPackageName(c);
+ Object module = CLASS_GET_MODULE_METHOD.invoke(c);
+ return (Boolean) MODULE_IS_EXPORTED_METHOD.invoke(module, pkg);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static final Method CLASS_GET_MODULE_METHOD;
+ private static final Method MODULE_IS_EXPORTED_METHOD;
+
+ static {
+ Method classGetModuleMethod;
+ Method moduleIsExportedMethod;
+ try {
+ classGetModuleMethod = Class.class.getMethod("getModule");
+ Class<?> moduleClass = classGetModuleMethod.getReturnType();
+ moduleIsExportedMethod = moduleClass.getMethod("isExported", String.class);
+ } catch (Exception e) {
+ classGetModuleMethod = null;
+ moduleIsExportedMethod = null;
+ }
+ CLASS_GET_MODULE_METHOD = classGetModuleMethod;
+ MODULE_IS_EXPORTED_METHOD = moduleIsExportedMethod;
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/Node.java b/src/main/java/com/google/escapevelocity/Node.java
new file mode 100644
index 0000000..a017afa
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/Node.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * A node in the parse tree.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+abstract class Node {
+ final String resourceName;
+ final int lineNumber;
+
+ Node(String resourceName, int lineNumber) {
+ this.resourceName = resourceName;
+ this.lineNumber = lineNumber;
+ }
+
+ /**
+ * Returns the result of evaluating this node in the given context. This result may be used as
+ * part of a further operation, for example evaluating {@code 2 + 3} to 5 in order to set
+ * {@code $x} to 5 in {@code #set ($x = 2 + 3)}. Or it may be used directly as part of the
+ * template output, for example evaluating replacing {@code name} by {@code Fred} in
+ * {@code My name is $name.}.
+ */
+ abstract Object evaluate(EvaluationContext context);
+
+ private String where() {
+ String where = "In expression on line " + lineNumber;
+ if (resourceName != null) {
+ where += " of " + resourceName;
+ }
+ return where;
+ }
+
+ EvaluationException evaluationException(String message) {
+ return new EvaluationException(where() + ": " + message);
+ }
+
+ EvaluationException evaluationException(Throwable cause) {
+ return new EvaluationException(where() + ": " + cause, cause);
+ }
+
+ /**
+ * Returns an empty node in the parse tree. This is used for example to represent the trivial
+ * "else" part of an {@code #if} that does not have an explicit {@code #else}.
+ */
+ static Node emptyNode(String resourceName, int lineNumber) {
+ return new Cons(resourceName, lineNumber, ImmutableList.<Node>of());
+ }
+
+ /**
+ * Create a new parse tree node that is the concatenation of the given ones. Evaluating the
+ * new node produces the same string as evaluating each of the given nodes and concatenating the
+ * result.
+ */
+ static Node cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) {
+ return new Cons(resourceName, lineNumber, nodes);
+ }
+
+ private static final class Cons extends Node {
+ private final ImmutableList<Node> nodes;
+
+ Cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) {
+ super(resourceName, lineNumber);
+ this.nodes = nodes;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ StringBuilder sb = new StringBuilder();
+ for (Node node : nodes) {
+ sb.append(node.evaluate(context));
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/ParseException.java b/src/main/java/com/google/escapevelocity/ParseException.java
new file mode 100644
index 0000000..7105f97
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/ParseException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+/**
+ * An exception that occurred while parsing a template.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+public class ParseException extends RuntimeException {
+ private static final long serialVersionUID = 1;
+
+ ParseException(String message, String resourceName, int lineNumber) {
+ super(message + ", " + where(resourceName, lineNumber));
+ }
+
+ ParseException(String message, String resourceName, int lineNumber, String context) {
+ super(message + ", " + where(resourceName, lineNumber) + ", at text starting: " + context);
+ }
+
+ private static String where(String resourceName, int lineNumber) {
+ if (resourceName == null) {
+ return "on line " + lineNumber;
+ } else {
+ return "on line " + lineNumber + " of " + resourceName;
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/Parser.java b/src/main/java/com/google/escapevelocity/Parser.java
new file mode 100644
index 0000000..4416c48
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/Parser.java
@@ -0,0 +1,1094 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Verify;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Chars;
+import com.google.common.primitives.Ints;
+import com.google.escapevelocity.DirectiveNode.SetNode;
+import com.google.escapevelocity.ExpressionNode.BinaryExpressionNode;
+import com.google.escapevelocity.ExpressionNode.NotExpressionNode;
+import com.google.escapevelocity.ReferenceNode.IndexReferenceNode;
+import com.google.escapevelocity.ReferenceNode.MemberReferenceNode;
+import com.google.escapevelocity.ReferenceNode.MethodReferenceNode;
+import com.google.escapevelocity.ReferenceNode.PlainReferenceNode;
+import com.google.escapevelocity.TokenNode.CommentTokenNode;
+import com.google.escapevelocity.TokenNode.ElseIfTokenNode;
+import com.google.escapevelocity.TokenNode.ElseTokenNode;
+import com.google.escapevelocity.TokenNode.EndTokenNode;
+import com.google.escapevelocity.TokenNode.EofNode;
+import com.google.escapevelocity.TokenNode.ForEachTokenNode;
+import com.google.escapevelocity.TokenNode.IfTokenNode;
+import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode;
+import com.google.escapevelocity.TokenNode.NestedTokenNode;
+import java.io.IOException;
+import java.io.LineNumberReader;
+import java.io.Reader;
+
+/**
+ * A parser that reads input from the given {@link Reader} and parses it to produce a
+ * {@link Template}.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+class Parser {
+ private static final int EOF = -1;
+
+ private final LineNumberReader reader;
+ private final String resourceName;
+ private final Template.ResourceOpener resourceOpener;
+
+ /**
+ * The invariant of this parser is that {@code c} is always the next character of interest.
+ * This means that we almost never have to "unget" a character by reading too far. For example,
+ * after we parse an integer, {@code c} will be the first character after the integer, which is
+ * exactly the state we will be in when there are no more digits.
+ *
+ * <p>Sometimes we need to read two characters ahead, and in that case we use {@link #pushback}.
+ */
+ private int c;
+
+ /**
+ * A single character of pushback. If this is not negative, the {@link #next()} method will
+ * return it instead of reading a character.
+ */
+ private int pushback = -1;
+
+ Parser(Reader reader, String resourceName, Template.ResourceOpener resourceOpener)
+ throws IOException {
+ this.reader = new LineNumberReader(reader);
+ this.reader.setLineNumber(1);
+ next();
+ this.resourceName = resourceName;
+ this.resourceOpener = resourceOpener;
+ }
+
+ /**
+ * Parse the input completely to produce a {@link Template}.
+ *
+ * <p>Parsing happens in two phases. First, we parse a sequence of "tokens", where tokens include
+ * entire references such as <pre>
+ * ${x.foo()[23]}
+ * </pre>or entire directives such as<pre>
+ * #set ($x = $y + $z)
+ * </pre>But tokens do not span complex constructs. For example,<pre>
+ * #if ($x == $y) something #end
+ * </pre>is three tokens:<pre>
+ * #if ($x == $y)
+ * (literal text " something ")
+ * #end
+ * </pre>
+ *
+ * <p>The second phase then takes the sequence of tokens and constructs a parse tree out of it.
+ * Some nodes in the parse tree will be unchanged from the token sequence, such as the <pre>
+ * ${x.foo()[23]}
+ * #set ($x = $y + $z)
+ * </pre> examples above. But a construct such as the {@code #if ... #end} mentioned above will
+ * become a single IfNode in the parse tree in the second phase.
+ *
+ * <p>The main reason for this approach is that Velocity has two kinds of lexical contexts. At the
+ * top level, there can be arbitrary literal text; references like <code>${x.foo()}</code>; and
+ * directives like {@code #if} or {@code #set}. Inside the parentheses of a directive, however,
+ * neither arbitrary text nor directives can appear, but expressions can, so we need to tokenize
+ * the inside of <pre>
+ * #if ($x == $a + $b)
+ * </pre> as the five tokens "$x", "==", "$a", "+", "$b". Rather than having a classical
+ * parser/lexer combination, where the lexer would need to switch between these two modes, we
+ * replace the lexer with an ad-hoc parser that is the first phase described above, and we
+ * define a simple parser over the resultant tokens that is the second phase.
+ */
+ Template parse() throws IOException {
+ ImmutableList<Node> tokens = parseTokens();
+ return new Reparser(tokens).reparse();
+ }
+
+ private ImmutableList<Node> parseTokens() throws IOException {
+ ImmutableList.Builder<Node> tokens = ImmutableList.builder();
+ Node token;
+ do {
+ token = parseNode();
+ tokens.add(token);
+ } while (!(token instanceof EofNode));
+ return tokens.build();
+ }
+
+ private int lineNumber() {
+ return reader.getLineNumber();
+ }
+
+ /**
+ * Gets the next character from the reader and assigns it to {@code c}. If there are no more
+ * characters, sets {@code c} to {@link #EOF} if it is not already.
+ */
+ private void next() throws IOException {
+ if (c != EOF) {
+ if (pushback < 0) {
+ c = reader.read();
+ } else {
+ c = pushback;
+ pushback = -1;
+ }
+ }
+ }
+
+ /**
+ * Saves the current character {@code c} to be read again, and sets {@code c} to the given
+ * {@code c1}. Suppose the text contains {@code xy} and we have just read {@code y}.
+ * So {@code c == 'y'}. Now if we execute {@code pushback('x')}, we will have
+ * {@code c == 'x'} and the next call to {@link #next()} will set {@code c == 'y'}. Subsequent
+ * calls to {@code next()} will continue reading from {@link #reader}. So the pushback
+ * essentially puts us back in the state we were in before we read {@code y}.
+ */
+ private void pushback(int c1) {
+ pushback = c;
+ c = c1;
+ }
+
+ /**
+ * If {@code c} is a space character, keeps reading until {@code c} is a non-space character or
+ * there are no more characters.
+ */
+ private void skipSpace() throws IOException {
+ while (Character.isWhitespace(c)) {
+ next();
+ }
+ }
+
+ /**
+ * Gets the next character from the reader, and if it is a space character, keeps reading until
+ * a non-space character is found.
+ */
+ private void nextNonSpace() throws IOException {
+ next();
+ skipSpace();
+ }
+
+ /**
+ * Skips any space in the reader, and then throws an exception if the first non-space character
+ * found is not the expected one. Sets {@code c} to the first character after that expected one.
+ */
+ private void expect(char expected) throws IOException {
+ skipSpace();
+ if (c == expected) {
+ next();
+ } else {
+ throw parseException("Expected " + expected);
+ }
+ }
+
+ /**
+ * Parses a single node from the reader, as part of the first parsing phase.
+ * <pre>{@code
+ * <template> -> <empty> |
+ * <directive> <template> |
+ * <non-directive> <template>
+ * }</pre>
+ */
+ private Node parseNode() throws IOException {
+ if (c == '#') {
+ next();
+ switch (c) {
+ case '#':
+ return parseLineComment();
+ case '*':
+ return parseBlockComment();
+ case '[':
+ return parseHashSquare();
+ case '{':
+ return parseDirective();
+ default:
+ if (isAsciiLetter(c)) {
+ return parseDirective();
+ } else {
+ // For consistency with Velocity, we treat # not followed by a letter or one of the
+ // characters above as a plain character, and we treat #$foo as a literal # followed by
+ // the reference $foo.
+ return parsePlainText('#');
+ }
+ }
+ }
+ if (c == EOF) {
+ return new EofNode(resourceName, lineNumber());
+ }
+ return parseNonDirective();
+ }
+
+ private Node parseHashSquare() throws IOException {
+ // We've just seen #[ which might be the start of a #[[quoted block]]#. If the next character
+ // is not another [ then it's not a quoted block, but it *is* a literal #[ followed by whatever
+ // that next character is.
+ assert c == '[';
+ next();
+ if (c != '[') {
+ return parsePlainText(new StringBuilder("#["));
+ }
+ int startLine = lineNumber();
+ next();
+ StringBuilder sb = new StringBuilder();
+ while (true) {
+ if (c == EOF) {
+ throw new ParseException(
+ "Unterminated #[[ - did not see matching ]]#", resourceName, startLine);
+ }
+ if (c == '#') {
+ // This might be the last character of ]]# or it might just be a random #.
+ int len = sb.length();
+ if (len > 1 && sb.charAt(len - 1) == ']' && sb.charAt(len - 2) == ']') {
+ next();
+ break;
+ }
+ }
+ sb.append((char) c);
+ next();
+ }
+ String quoted = sb.substring(0, sb.length() - 2);
+ return new ConstantExpressionNode(resourceName, lineNumber(), quoted);
+ }
+
+ /**
+ * Parses a single non-directive node from the reader.
+ * <pre>{@code
+ * <non-directive> -> <reference> |
+ * <text containing neither $ nor #>
+ * }</pre>
+ */
+ private Node parseNonDirective() throws IOException {
+ if (c == '$') {
+ next();
+ if (isAsciiLetter(c) || c == '{') {
+ return parseReference();
+ } else {
+ return parsePlainText('$');
+ }
+ } else {
+ int firstChar = c;
+ next();
+ return parsePlainText(firstChar);
+ }
+ }
+
+ /**
+ * Parses a single directive token from the reader. Directives can be spelled with or without
+ * braces, for example {@code #if} or {@code #{if}}. We omit the brace spelling in the productions
+ * here: <pre>{@code
+ * <directive> -> <if-token> |
+ * <else-token> |
+ * <elseif-token> |
+ * <end-token> |
+ * <foreach-token> |
+ * <set-token> |
+ * <parse-token> |
+ * <macro-token> |
+ * <macro-call> |
+ * <comment>
+ * }</pre>
+ */
+ private Node parseDirective() throws IOException {
+ String directive;
+ if (c == '{') {
+ next();
+ directive = parseId("Directive inside #{...}");
+ expect('}');
+ } else {
+ directive = parseId("Directive");
+ }
+ Node node;
+ switch (directive) {
+ case "end":
+ node = new EndTokenNode(resourceName, lineNumber());
+ break;
+ case "if":
+ case "elseif":
+ node = parseIfOrElseIf(directive);
+ break;
+ case "else":
+ node = new ElseTokenNode(resourceName, lineNumber());
+ break;
+ case "foreach":
+ node = parseForEach();
+ break;
+ case "set":
+ node = parseSet();
+ break;
+ case "parse":
+ node = parseParse();
+ break;
+ case "macro":
+ node = parseMacroDefinition();
+ break;
+ default:
+ node = parsePossibleMacroCall(directive);
+ }
+ // Velocity skips a newline after any directive.
+ // TODO(emcmanus): in fact it also skips space before the newline, which should be implemented.
+ if (c == '\n') {
+ next();
+ }
+ return node;
+ }
+
+ /**
+ * Parses the condition following {@code #if} or {@code #elseif}.
+ * <pre>{@code
+ * <if-token> -> #if ( <condition> )
+ * <elseif-token> -> #elseif ( <condition> )
+ * }</pre>
+ *
+ * @param directive either {@code "if"} or {@code "elseif"}.
+ */
+ private Node parseIfOrElseIf(String directive) throws IOException {
+ expect('(');
+ ExpressionNode condition = parseExpression();
+ expect(')');
+ return directive.equals("if") ? new IfTokenNode(condition) : new ElseIfTokenNode(condition);
+ }
+
+ /**
+ * Parses a {@code #foreach} token from the reader. <pre>{@code
+ * <foreach-token> -> #foreach ( $<id> in <expression> )
+ * }</pre>
+ */
+ private Node parseForEach() throws IOException {
+ expect('(');
+ expect('$');
+ String var = parseId("For-each variable");
+ skipSpace();
+ boolean bad = false;
+ if (c != 'i') {
+ bad = true;
+ } else {
+ next();
+ if (c != 'n') {
+ bad = true;
+ }
+ }
+ if (bad) {
+ throw parseException("Expected 'in' for #foreach");
+ }
+ next();
+ ExpressionNode collection = parseExpression();
+ expect(')');
+ return new ForEachTokenNode(var, collection);
+ }
+
+ /**
+ * Parses a {@code #set} token from the reader. <pre>{@code
+ * <set-token> -> #set ( $<id> = <expression>)
+ * }</pre>
+ */
+ private Node parseSet() throws IOException {
+ expect('(');
+ expect('$');
+ String var = parseId("#set variable");
+ expect('=');
+ ExpressionNode expression = parseExpression();
+ expect(')');
+ return new SetNode(var, expression);
+ }
+
+ /**
+ * Parses a {@code #parse} token from the reader. <pre>{@code
+ * <parse-token> -> #parse ( <string-literal> )
+ * }</pre>
+ *
+ * <p>The way this works is inconsistent with Velocity. In Velocity, the {@code #parse} directive
+ * is evaluated when it is encountered during template evaluation. That means that the argument
+ * can be a variable, and it also means that you can use {@code #if} to choose whether or not
+ * to do the {@code #parse}. Neither of those is true in EscapeVelocity. The contents of the
+ * {@code #parse} are integrated into the containing template pretty much as if they had been
+ * written inline. That also means that EscapeVelocity allows forward references to macros
+ * inside {@code #parse} directives, which Velocity does not.
+ */
+ private Node parseParse() throws IOException {
+ expect('(');
+ skipSpace();
+ if (c != '"' && c != '\'') {
+ throw parseException("#parse only supported with string literal argument");
+ }
+ ExpressionNode nestedResourceNameExpression = parseStringLiteral(c, false);
+ String nestedResourceName = nestedResourceNameExpression.evaluate(null).toString();
+ expect(')');
+ try (Reader nestedReader = resourceOpener.openResource(nestedResourceName)) {
+ Parser nestedParser = new Parser(nestedReader, nestedResourceName, resourceOpener);
+ ImmutableList<Node> nestedTokens = nestedParser.parseTokens();
+ return new NestedTokenNode(nestedResourceName, nestedTokens);
+ }
+ }
+
+ /**
+ * Parses a {@code #macro} token from the reader. <pre>{@code
+ * <macro-token> -> #macro ( <id> <macro-parameter-list> )
+ * <macro-parameter-list> -> <empty> |
+ * $<id> <macro-parameter-list>
+ * }</pre>
+ *
+ * <p>Macro parameters are optionally separated by commas.
+ */
+ private Node parseMacroDefinition() throws IOException {
+ expect('(');
+ skipSpace();
+ String name = parseId("Macro name");
+ ImmutableList.Builder<String> parameterNames = ImmutableList.builder();
+ while (true) {
+ skipSpace();
+ if (c == ')') {
+ next();
+ break;
+ }
+ if (c == ',') {
+ next();
+ skipSpace();
+ }
+ if (c != '$') {
+ throw parseException("Macro parameters should look like $name");
+ }
+ next();
+ parameterNames.add(parseId("Macro parameter name"));
+ }
+ return new MacroDefinitionTokenNode(resourceName, lineNumber(), name, parameterNames.build());
+ }
+
+ /**
+ * Parses an identifier after {@code #} that is not one of the standard directives. The assumption
+ * is that it is a call of a macro that is defined in the template. Macro definitions are
+ * extracted from the template during the second parsing phase (and not during evaluation of the
+ * template as you might expect). This means that a macro can be called before it is defined.
+ * <pre>{@code
+ * <macro-call> -> # <id> ( <expression-list> )
+ * <expression-list> -> <empty> |
+ * <expression> <optional-comma> <expression-list>
+ * <optional-comma> -> <empty> | ,
+ * }</pre>
+ */
+ private Node parsePossibleMacroCall(String directive) throws IOException {
+ skipSpace();
+ if (c != '(') {
+ throw parseException("Unrecognized directive #" + directive);
+ }
+ next();
+ ImmutableList.Builder<Node> parameterNodes = ImmutableList.builder();
+ while (true) {
+ skipSpace();
+ if (c == ')') {
+ next();
+ break;
+ }
+ parameterNodes.add(parsePrimary());
+ if (c == ',') {
+ // The documentation doesn't say so, but you can apparently have an optional comma in
+ // macro calls.
+ next();
+ }
+ }
+ return new DirectiveNode.MacroCallNode(
+ resourceName, lineNumber(), directive, parameterNodes.build());
+ }
+
+ /**
+ * Parses and discards a line comment, which is {@code ##} followed by any number of characters
+ * up to and including the next newline.
+ */
+ private Node parseLineComment() throws IOException {
+ int lineNumber = lineNumber();
+ while (c != '\n' && c != EOF) {
+ next();
+ }
+ next();
+ return new CommentTokenNode(resourceName, lineNumber);
+ }
+
+ /**
+ * Parses and discards a block comment, which is {@code #*} followed by everything up to and
+ * including the next {@code *#}.
+ */
+ private Node parseBlockComment() throws IOException {
+ assert c == '*';
+ int startLine = lineNumber();
+ int lastC = '\0';
+ next();
+ // Consistently with Velocity, we do not make it an error if a #* comment is not closed.
+ while (!(lastC == '*' && c == '#') && c != EOF) {
+ lastC = c;
+ next();
+ }
+ next(); // this may read EOF twice, which works
+ return new CommentTokenNode(resourceName, startLine);
+ }
+
+ /**
+ * Parses plain text, which is text that contains neither {@code $} nor {@code #}. The given
+ * {@code firstChar} is the first character of the plain text, and {@link #c} is the second
+ * (if the plain text is more than one character).
+ */
+ private Node parsePlainText(int firstChar) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ sb.appendCodePoint(firstChar);
+ return parsePlainText(sb);
+ }
+
+ private Node parsePlainText(StringBuilder sb) throws IOException {
+ literal:
+ while (true) {
+ switch (c) {
+ case EOF:
+ case '$':
+ case '#':
+ break literal;
+ default:
+ // Just some random character.
+ }
+ sb.appendCodePoint(c);
+ next();
+ }
+ return new ConstantExpressionNode(resourceName, lineNumber(), sb.toString());
+ }
+
+ /**
+ * Parses a reference, which is everything that can start with a {@code $}. References can
+ * optionally be enclosed in braces, so {@code $x} and {@code ${x}} are the same. Braces are
+ * useful when text after the reference would otherwise be parsed as part of it. For example,
+ * {@code ${x}y} is a reference to the variable {@code $x}, followed by the plain text {@code y}.
+ * Of course {@code $xy} would be a reference to the variable {@code $xy}.
+ * <pre>{@code
+ * <reference> -> $<reference-no-brace> |
+ * ${<reference-no-brace>}
+ * }</pre>
+ *
+ * <p>On entry to this method, {@link #c} is the character immediately after the {@code $}.
+ */
+ private Node parseReference() throws IOException {
+ if (c == '{') {
+ next();
+ if (!isAsciiLetter(c)) {
+ return parsePlainText(new StringBuilder("${"));
+ }
+ ReferenceNode node = parseReferenceNoBrace();
+ expect('}');
+ return node;
+ } else {
+ return parseReferenceNoBrace();
+ }
+ }
+
+ /**
+ * Same as {@link #parseReference()}, except it really must be a reference. A {@code $} in
+ * normal text doesn't start a reference if it is not followed by an identifier. But in an
+ * expression, for example in {@code #if ($x == 23)}, {@code $} must be followed by an
+ * identifier.
+ */
+ private ReferenceNode parseRequiredReference() throws IOException {
+ if (c == '{') {
+ next();
+ ReferenceNode node = parseReferenceNoBrace();
+ expect('}');
+ return node;
+ } else {
+ return parseReferenceNoBrace();
+ }
+ }
+
+ /**
+ * Parses a reference, in the simple form without braces.
+ * <pre>{@code
+ * <reference-no-brace> -> <id><reference-suffix>
+ * }</pre>
+ */
+ private ReferenceNode parseReferenceNoBrace() throws IOException {
+ String id = parseId("Reference");
+ ReferenceNode lhs = new PlainReferenceNode(resourceName, lineNumber(), id);
+ return parseReferenceSuffix(lhs);
+ }
+
+ /**
+ * Parses the modifiers that can appear at the tail of a reference.
+ * <pre>{@code
+ * <reference-suffix> -> <empty> |
+ * <reference-member> |
+ * <reference-index>
+ * }</pre>
+ *
+ * @param lhs the reference node representing the first part of the reference
+ * {@code $x} in {@code $x.foo} or {@code $x.foo()}, or later {@code $x.y} in {@code $x.y.z}.
+ */
+ private ReferenceNode parseReferenceSuffix(ReferenceNode lhs) throws IOException {
+ switch (c) {
+ case '.':
+ return parseReferenceMember(lhs);
+ case '[':
+ return parseReferenceIndex(lhs);
+ default:
+ return lhs;
+ }
+ }
+
+ /**
+ * Parses a reference member, which is either a property reference like {@code $x.y} or a method
+ * call like {@code $x.y($z)}.
+ * <pre>{@code
+ * <reference-member> -> .<id><reference-property-or-method><reference-suffix>
+ * <reference-property-or-method> -> <id> |
+ * <id> ( <method-parameter-list> )
+ * }</pre>
+ *
+ * @param lhs the reference node representing what appears to the left of the dot, like the
+ * {@code $x} in {@code $x.foo} or {@code $x.foo()}.
+ */
+ private ReferenceNode parseReferenceMember(ReferenceNode lhs) throws IOException {
+ assert c == '.';
+ next();
+ if (!isAsciiLetter(c)) {
+ // We've seen something like `$foo.!`, so it turns out it's not a member after all.
+ pushback('.');
+ return lhs;
+ }
+ String id = parseId("Member");
+ ReferenceNode reference;
+ if (c == '(') {
+ reference = parseReferenceMethodParams(lhs, id);
+ } else {
+ reference = new MemberReferenceNode(lhs, id);
+ }
+ return parseReferenceSuffix(reference);
+ }
+
+ /**
+ * Parses the parameters to a method reference, like {@code $foo.bar($a, $b)}.
+ * <pre>{@code
+ * <method-parameter-list> -> <empty> |
+ * <non-empty-method-parameter-list>
+ * <non-empty-method-parameter-list> -> <expression> |
+ * <expression> , <non-empty-method-parameter-list>
+ * }</pre>
+ *
+ * @param lhs the reference node representing what appears to the left of the dot, like the
+ * {@code $x} in {@code $x.foo()}.
+ */
+ private ReferenceNode parseReferenceMethodParams(ReferenceNode lhs, String id)
+ throws IOException {
+ assert c == '(';
+ nextNonSpace();
+ ImmutableList.Builder<ExpressionNode> args = ImmutableList.builder();
+ if (c != ')') {
+ args.add(parseExpression());
+ while (c == ',') {
+ nextNonSpace();
+ args.add(parseExpression());
+ }
+ if (c != ')') {
+ throw parseException("Expected )");
+ }
+ }
+ assert c == ')';
+ next();
+ return new MethodReferenceNode(lhs, id, args.build());
+ }
+
+ /**
+ * Parses an index suffix to a method, like {@code $x[$i]}.
+ * <pre>{@code
+ * <reference-index> -> [ <expression> ]
+ * }</pre>
+ *
+ * @param lhs the reference node representing what appears to the left of the dot, like the
+ * {@code $x} in {@code $x[$i]}.
+ */
+ private ReferenceNode parseReferenceIndex(ReferenceNode lhs) throws IOException {
+ assert c == '[';
+ next();
+ ExpressionNode index = parseExpression();
+ if (c != ']') {
+ throw parseException("Expected ]");
+ }
+ next();
+ ReferenceNode reference = new IndexReferenceNode(lhs, index);
+ return parseReferenceSuffix(reference);
+ }
+
+ enum Operator {
+ /**
+ * A dummy operator with low precedence. When parsing subexpressions, we always stop when we
+ * reach an operator of lower precedence than the "current precedence". For example, when
+ * parsing {@code 1 + 2 * 3 + 4}, we'll stop parsing the subexpression {@code * 3 + 4} when
+ * we reach the {@code +} because it has lower precedence than {@code *}. This dummy operator,
+ * then, behaves like {@code +} when the minimum precedence is {@code *}. We also return it
+ * if we're looking for an operator and don't find one. If this operator is {@code ⊙}, it's as
+ * if our expressions are bracketed with it, like {@code ⊙ 1 + 2 * 3 + 4 ⊙}.
+ */
+ STOP("", 0),
+
+ // If a one-character operator is a prefix of a two-character operator, like < and <=, then
+ // the one-character operator must come first.
+ OR("||", 1),
+ AND("&&", 2),
+ EQUAL("==", 3), NOT_EQUAL("!=", 3),
+ LESS("<", 4), LESS_OR_EQUAL("<=", 4), GREATER(">", 4), GREATER_OR_EQUAL(">=", 4),
+ PLUS("+", 5), MINUS("-", 5),
+ TIMES("*", 6), DIVIDE("/", 6), REMAINDER("%", 6);
+
+ final String symbol;
+ final int precedence;
+
+ Operator(String symbol, int precedence) {
+ this.symbol = symbol;
+ this.precedence = precedence;
+ }
+
+ @Override
+ public String toString() {
+ return symbol;
+ }
+ }
+
+ /**
+ * Maps a code point to the operators that begin with that code point. For example, maps
+ * {@code <} to {@code LESS} and {@code LESS_OR_EQUAL}.
+ */
+ private static final ImmutableListMultimap<Integer, Operator> CODE_POINT_TO_OPERATORS;
+ static {
+ ImmutableListMultimap.Builder<Integer, Operator> builder = ImmutableListMultimap.builder();
+ for (Operator operator : Operator.values()) {
+ if (operator != Operator.STOP) {
+ builder.put((int) operator.symbol.charAt(0), operator);
+ }
+ }
+ CODE_POINT_TO_OPERATORS = builder.build();
+ }
+
+ /**
+ * Parses an expression, which can occur within a directive like {@code #if} or {@code #set},
+ * or within a reference like {@code $x[$a + $b]} or {@code $x.m($a + $b)}.
+ * <pre>{@code
+ * <expression> -> <and-expression> |
+ * <expression> || <and-expression>
+ * <and-expression> -> <relational-expression> |
+ * <and-expression> && <relational-expression>
+ * <equality-exression> -> <relational-expression> |
+ * <equality-expression> <equality-op> <relational-expression>
+ * <equality-op> -> == | !=
+ * <relational-expression> -> <additive-expression> |
+ * <relational-expression> <relation> <additive-expression>
+ * <relation> -> < | <= | > | >=
+ * <additive-expression> -> <multiplicative-expression> |
+ * <additive-expression> <add-op> <multiplicative-expression>
+ * <add-op> -> + | -
+ * <multiplicative-expression> -> <unary-expression> |
+ * <multiplicative-expression> <mult-op> <unary-expression>
+ * <mult-op> -> * | / | %
+ * }</pre>
+ */
+ private ExpressionNode parseExpression() throws IOException {
+ ExpressionNode lhs = parseUnaryExpression();
+ return new OperatorParser().parse(lhs, 1);
+ }
+
+ /**
+ * An operator-precedence parser for the binary operations we understand. It implements an
+ * <a href="http://en.wikipedia.org/wiki/Operator-precedence_parser">algorithm</a> from Wikipedia
+ * that uses recursion rather than having an explicit stack of operators and values.
+ */
+ private class OperatorParser {
+ /**
+ * The operator we have just scanned, in the same way that {@link #c} is the character we have
+ * just read. If we were not able to scan an operator, this will be {@link Operator#STOP}.
+ */
+ private Operator currentOperator;
+
+ OperatorParser() throws IOException {
+ nextOperator();
+ }
+
+ /**
+ * Parse a subexpression whose left-hand side is {@code lhs} and where we only consider
+ * operators with precedence at least {@code minPrecedence}.
+ *
+ * @return the parsed subexpression
+ */
+ ExpressionNode parse(ExpressionNode lhs, int minPrecedence) throws IOException {
+ while (currentOperator.precedence >= minPrecedence) {
+ Operator operator = currentOperator;
+ ExpressionNode rhs = parseUnaryExpression();
+ nextOperator();
+ while (currentOperator.precedence > operator.precedence) {
+ rhs = parse(rhs, currentOperator.precedence);
+ }
+ lhs = new BinaryExpressionNode(lhs, operator, rhs);
+ }
+ return lhs;
+ }
+
+ /**
+ * Updates {@link #currentOperator} to be an operator read from the input,
+ * or {@link Operator#STOP} if there is none.
+ */
+ private void nextOperator() throws IOException {
+ skipSpace();
+ ImmutableList<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c);
+ if (possibleOperators.isEmpty()) {
+ currentOperator = Operator.STOP;
+ return;
+ }
+ char firstChar = Chars.checkedCast(c);
+ next();
+ Operator operator = null;
+ for (Operator possibleOperator : possibleOperators) {
+ if (possibleOperator.symbol.length() == 1) {
+ Verify.verify(operator == null);
+ operator = possibleOperator;
+ } else if (possibleOperator.symbol.charAt(1) == c) {
+ next();
+ operator = possibleOperator;
+ }
+ }
+ if (operator == null) {
+ throw parseException(
+ "Expected " + Iterables.getOnlyElement(possibleOperators) + ", not just " + firstChar);
+ }
+ currentOperator = operator;
+ }
+ }
+
+ /**
+ * Parses an expression not containing any operators (except inside parentheses).
+ * <pre>{@code
+ * <unary-expression> -> <primary> |
+ * ( <expression> ) |
+ * ! <unary-expression>
+ * }</pre>
+ */
+ private ExpressionNode parseUnaryExpression() throws IOException {
+ skipSpace();
+ ExpressionNode node;
+ if (c == '(') {
+ nextNonSpace();
+ node = parseExpression();
+ expect(')');
+ skipSpace();
+ return node;
+ } else if (c == '!') {
+ next();
+ node = new NotExpressionNode(parseUnaryExpression());
+ skipSpace();
+ return node;
+ } else {
+ return parsePrimary();
+ }
+ }
+
+ /**
+ * Parses an expression containing only literals or references.
+ * <pre>{@code
+ * <primary> -> <reference> |
+ * <string-literal> |
+ * <integer-literal> |
+ * <boolean-literal>
+ * }</pre>
+ */
+ private ExpressionNode parsePrimary() throws IOException {
+ ExpressionNode node;
+ if (c == '$') {
+ next();
+ node = parseRequiredReference();
+ } else if (c == '"') {
+ node = parseStringLiteral(c, true);
+ } else if (c == '\'') {
+ node = parseStringLiteral(c, false);
+ } else if (c == '-') {
+ // Velocity does not have a negation operator. If we see '-' it must be the start of a
+ // negative integer literal.
+ next();
+ node = parseIntLiteral("-");
+ } else if (isAsciiDigit(c)) {
+ node = parseIntLiteral("");
+ } else if (isAsciiLetter(c)) {
+ node = parseBooleanLiteral();
+ } else {
+ throw parseException("Expected an expression");
+ }
+ skipSpace();
+ return node;
+ }
+
+ /**
+ * Parses a string literal, which may contain references to be expanded. Examples are
+ * {@code "foo"} or {@code "foo${bar}baz"}.
+ * <pre>{@code
+ * <string-literal> -> <double-quote-literal> | <single-quote-literal>
+ * <double-quote-literal> -> " <double-quote-string-contents> "
+ * <double-quote-string-contents> -> <empty> |
+ * <reference> <double-quote-string-contents> |
+ * <character-other-than-"> <double-quote-string-contents>
+ * <single-quote-literal> -> ' <single-quote-string-contents> '
+ * <single-quote-string-contents> -> <empty> |
+ * <character-other-than-'> <single-quote-string-contents>
+ * }</pre>
+ */
+ private ExpressionNode parseStringLiteral(int quote, boolean allowReferences)
+ throws IOException {
+ assert c == quote;
+ next();
+ ImmutableList.Builder<Node> nodes = ImmutableList.builder();
+ StringBuilder sb = new StringBuilder();
+ while (c != quote) {
+ switch (c) {
+ case '\n':
+ case EOF:
+ throw parseException("Unterminated string constant");
+ case '\\':
+ throw parseException(
+ "Escapes in string constants are not currently supported");
+ case '$':
+ if (allowReferences) {
+ if (sb.length() > 0) {
+ nodes.add(new ConstantExpressionNode(resourceName, lineNumber(), sb.toString()));
+ sb.setLength(0);
+ }
+ next();
+ nodes.add(parseReference());
+ break;
+ }
+ // fall through
+ default:
+ sb.appendCodePoint(c);
+ next();
+ }
+ }
+ next();
+ if (sb.length() > 0) {
+ nodes.add(new ConstantExpressionNode(resourceName, lineNumber(), sb.toString()));
+ }
+ return new StringLiteralNode(resourceName, lineNumber(), nodes.build());
+ }
+
+ private static class StringLiteralNode extends ExpressionNode {
+ private final ImmutableList<Node> nodes;
+
+ StringLiteralNode(String resourceName, int lineNumber, ImmutableList<Node> nodes) {
+ super(resourceName, lineNumber);
+ this.nodes = nodes;
+ }
+
+ @Override
+ Object evaluate(EvaluationContext context) {
+ StringBuilder sb = new StringBuilder();
+ for (Node node : nodes) {
+ sb.append(node.evaluate(context));
+ }
+ return sb.toString();
+ }
+ }
+
+ private ExpressionNode parseIntLiteral(String prefix) throws IOException {
+ StringBuilder sb = new StringBuilder(prefix);
+ while (isAsciiDigit(c)) {
+ sb.appendCodePoint(c);
+ next();
+ }
+ Integer value = Ints.tryParse(sb.toString());
+ if (value == null) {
+ throw parseException("Invalid integer: " + sb);
+ }
+ return new ConstantExpressionNode(resourceName, lineNumber(), value);
+ }
+
+ /**
+ * Parses a boolean literal, either {@code true} or {@code false}.
+ * <boolean-literal> -> true |
+ * false
+ */
+ private ExpressionNode parseBooleanLiteral() throws IOException {
+ String s = parseId("Identifier without $");
+ boolean value;
+ if (s.equals("true")) {
+ value = true;
+ } else if (s.equals("false")) {
+ value = false;
+ } else {
+ throw parseException("Identifier in expression must be preceded by $ or be true or false");
+ }
+ return new ConstantExpressionNode(resourceName, lineNumber(), value);
+ }
+
+ private static final CharMatcher ASCII_LETTER =
+ CharMatcher.inRange('A', 'Z')
+ .or(CharMatcher.inRange('a', 'z'))
+ .precomputed();
+
+ private static final CharMatcher ASCII_DIGIT =
+ CharMatcher.inRange('0', '9')
+ .precomputed();
+
+ private static final CharMatcher ID_CHAR =
+ ASCII_LETTER
+ .or(ASCII_DIGIT)
+ .or(CharMatcher.anyOf("-_"))
+ .precomputed();
+
+ private static boolean isAsciiLetter(int c) {
+ return (char) c == c && ASCII_LETTER.matches((char) c);
+ }
+
+ private static boolean isAsciiDigit(int c) {
+ return (char) c == c && ASCII_DIGIT.matches((char) c);
+ }
+
+ private static boolean isIdChar(int c) {
+ return (char) c == c && ID_CHAR.matches((char) c);
+ }
+
+ /**
+ * Parse an identifier as specified by the
+ * <a href="http://velocity.apache.org/engine/devel/vtl-reference-guide.html#Variables">VTL
+ * </a>. Identifiers are ASCII: starts with a letter, then letters, digits, {@code -} and
+ * {@code _}.
+ */
+ private String parseId(String what) throws IOException {
+ if (!isAsciiLetter(c)) {
+ throw parseException(what + " should start with an ASCII letter");
+ }
+ StringBuilder id = new StringBuilder();
+ while (isIdChar(c)) {
+ id.appendCodePoint(c);
+ next();
+ }
+ return id.toString();
+ }
+
+ /**
+ * Returns an exception to be thrown describing a parse error with the given message, and
+ * including information about where it occurred.
+ */
+ private ParseException parseException(String message) throws IOException {
+ StringBuilder context = new StringBuilder();
+ if (c == EOF) {
+ context.append("EOF");
+ } else {
+ int count = 0;
+ while (c != EOF && count < 20) {
+ context.appendCodePoint(c);
+ next();
+ count++;
+ }
+ if (c != EOF) {
+ context.append("...");
+ }
+ }
+ return new ParseException(message, resourceName, lineNumber(), context.toString());
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/ReferenceNode.java b/src/main/java/com/google/escapevelocity/ReferenceNode.java
new file mode 100644
index 0000000..622388f
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Primitives;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A node in the parse tree that is a reference. A reference is anything beginning with {@code $},
+ * such as {@code $x} or {@code $x[$i].foo($j)}.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+abstract class ReferenceNode extends ExpressionNode {
+ ReferenceNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ /**
+ * A node in the parse tree that is a plain reference such as {@code $x}. This node may appear
+ * inside a more complex reference like {@code $x.foo}.
+ */
+ static class PlainReferenceNode extends ReferenceNode {
+ final String id;
+
+ PlainReferenceNode(String resourceName, int lineNumber, String id) {
+ super(resourceName, lineNumber);
+ this.id = id;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ if (context.varIsDefined(id)) {
+ return context.getVar(id);
+ } else {
+ throw evaluationException("Undefined reference $" + id);
+ }
+ }
+
+ @Override
+ boolean isDefinedAndTrue(EvaluationContext context) {
+ if (context.varIsDefined(id)) {
+ return isTrue(context);
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * A node in the parse tree that is a reference to a property of another reference, like
+ * {@code $x.foo} or {@code $x[$i].foo}.
+ */
+ static class MemberReferenceNode extends ReferenceNode {
+ final ReferenceNode lhs;
+ final String id;
+
+ MemberReferenceNode(ReferenceNode lhs, String id) {
+ super(lhs.resourceName, lhs.lineNumber);
+ this.lhs = lhs;
+ this.id = id;
+ }
+
+ private static final String[] PREFIXES = {"get", "is"};
+ private static final boolean[] CHANGE_CASE = {false, true};
+
+ @Override Object evaluate(EvaluationContext context) {
+ Object lhsValue = lhs.evaluate(context);
+ if (lhsValue == null) {
+ throw evaluationException("Cannot get member " + id + " of null value");
+ }
+ // If this is a Map, then Velocity looks up the property in the map.
+ if (lhsValue instanceof Map<?, ?>) {
+ Map<?, ?> map = (Map<?, ?>) lhsValue;
+ return map.get(id);
+ }
+ // Velocity specifies that, given a reference .foo, it will first look for getfoo() and then
+ // for getFoo(), and likewise given .Foo it will look for getFoo() and then getfoo().
+ for (String prefix : PREFIXES) {
+ for (boolean changeCase : CHANGE_CASE) {
+ String baseId = changeCase ? changeInitialCase(id) : id;
+ String methodName = prefix + baseId;
+ Optional<Method> maybeMethod =
+ context.publicMethodsWithName(lhsValue.getClass(), methodName).stream()
+ .filter(m -> m.getParameterTypes().length == 0)
+ .findFirst();
+ if (maybeMethod.isPresent()) {
+ Method method = maybeMethod.get();
+ if (!prefix.equals("is") || method.getReturnType().equals(boolean.class)) {
+ // Don't consider methods that happen to be called isFoo() but don't return boolean.
+ return invokeMethod(method, lhsValue, ImmutableList.of());
+ }
+ }
+ }
+ }
+ throw evaluationException(
+ "Member " + id + " does not correspond to a public getter of " + lhsValue
+ + ", a " + lhsValue.getClass().getName());
+ }
+
+ private static String changeInitialCase(String id) {
+ int initial = id.codePointAt(0);
+ String rest = id.substring(Character.charCount(initial));
+ if (Character.isUpperCase(initial)) {
+ initial = Character.toLowerCase(initial);
+ } else if (Character.isLowerCase(initial)) {
+ initial = Character.toUpperCase(initial);
+ }
+ return new StringBuilder().appendCodePoint(initial).append(rest).toString();
+ }
+ }
+
+ /**
+ * A node in the parse tree that is an indexing of a reference, like {@code $x[0]} or
+ * {@code $x.foo[$i]}. Indexing is array indexing or calling the {@code get} method of a list
+ * or a map.
+ */
+ static class IndexReferenceNode extends ReferenceNode {
+ final ReferenceNode lhs;
+ final ExpressionNode index;
+
+ IndexReferenceNode(ReferenceNode lhs, ExpressionNode index) {
+ super(lhs.resourceName, lhs.lineNumber);
+ this.lhs = lhs;
+ this.index = index;
+ }
+
+ @Override Object evaluate(EvaluationContext context) {
+ Object lhsValue = lhs.evaluate(context);
+ if (lhsValue == null) {
+ throw evaluationException("Cannot index null value");
+ }
+ if (lhsValue instanceof List<?>) {
+ Object indexValue = index.evaluate(context);
+ if (!(indexValue instanceof Integer)) {
+ throw evaluationException("List index is not an integer: " + indexValue);
+ }
+ List<?> lhsList = (List<?>) lhsValue;
+ int i = (Integer) indexValue;
+ if (i < 0 || i >= lhsList.size()) {
+ throw evaluationException(
+ "List index " + i + " is not valid for list of size " + lhsList.size());
+ }
+ return lhsList.get(i);
+ } else if (lhsValue instanceof Map<?, ?>) {
+ Object indexValue = index.evaluate(context);
+ Map<?, ?> lhsMap = (Map<?, ?>) lhsValue;
+ return lhsMap.get(indexValue);
+ } else {
+ // In general, $x[$y] is equivalent to $x.get($y). We've covered the most common cases
+ // above, but for other cases like Multimap we resort to evaluating the equivalent form.
+ MethodReferenceNode node = new MethodReferenceNode(lhs, "get", ImmutableList.of(index));
+ return node.evaluate(context);
+ }
+ }
+ }
+
+ /**
+ * A node in the parse tree representing a method reference, like {@code $list.size()}.
+ */
+ static class MethodReferenceNode extends ReferenceNode {
+ final ReferenceNode lhs;
+ final String id;
+ final List<ExpressionNode> args;
+
+ MethodReferenceNode(ReferenceNode lhs, String id, List<ExpressionNode> args) {
+ super(lhs.resourceName, lhs.lineNumber);
+ this.lhs = lhs;
+ this.id = id;
+ this.args = args;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>Evaluating a method expression such as {@code $x.foo($y)} involves looking at the actual
+ * types of {@code $x} and {@code $y}. The type of {@code $x} must have a public method
+ * {@code foo} with a parameter type that is compatible with {@code $y}.
+ *
+ * <p>Currently we don't allow there to be more than one matching method. That is a difference
+ * from Velocity, which blithely allows you to invoke {@link List#remove(int)} even though it
+ * can't really know that you didn't mean to invoke {@link List#remove(Object)} with an Object
+ * that just happens to be an Integer.
+ *
+ * <p>The method to be invoked must be visible in a public class or interface that is either the
+ * class of {@code $x} itself or one of its supertypes. Allowing supertypes is important because
+ * you may want to invoke a public method like {@link List#size()} on a list whose class is not
+ * public, such as the list returned by {@link java.util.Collections#singletonList}.
+ */
+ @Override Object evaluate(EvaluationContext context) {
+ Object lhsValue = lhs.evaluate(context);
+ if (lhsValue == null) {
+ throw evaluationException("Cannot invoke method " + id + " on null value");
+ }
+ try {
+ return evaluate(context, lhsValue, lhsValue.getClass());
+ } catch (EvaluationException e) {
+ // If this is a Class, try invoking a static method of the class it refers to.
+ // This is what Apache Velocity does. If the method exists as both an instance method of
+ // Class and a static method of the referenced class, then it is the instance method of
+ // Class that wins, again consistent with Velocity.
+ if (lhsValue instanceof Class<?>) {
+ return evaluate(context, null, (Class<?>) lhsValue);
+ }
+ throw e;
+ }
+ }
+
+ private Object evaluate(EvaluationContext context, Object lhsValue, Class<?> targetClass) {
+ List<Object> argValues = args.stream()
+ .map(arg -> arg.evaluate(context))
+ .collect(toList());
+ ImmutableSet<Method> publicMethodsWithName = context.publicMethodsWithName(targetClass, id);
+ if (publicMethodsWithName.isEmpty()) {
+ throw evaluationException("No method " + id + " in " + targetClass.getName());
+ }
+ List<Method> compatibleMethods = publicMethodsWithName.stream()
+ .filter(method -> compatibleArgs(method.getParameterTypes(), argValues))
+ .collect(toList());
+ // TODO(emcmanus): support varargs, if it's useful
+ if (compatibleMethods.size() > 1) {
+ compatibleMethods =
+ compatibleMethods.stream().filter(method -> !method.isSynthetic()).collect(toList());
+ }
+ switch (compatibleMethods.size()) {
+ case 0:
+ throw evaluationException(
+ "Parameters for method " + id + " have wrong types: " + argValues);
+ case 1:
+ return invokeMethod(Iterables.getOnlyElement(compatibleMethods), lhsValue, argValues);
+ default:
+ throw evaluationException(
+ "Ambiguous method invocation, could be one of:\n "
+ + Joiner.on("\n ").join(compatibleMethods));
+ }
+ }
+
+ /**
+ * Determines if the given argument list is compatible with the given parameter types. This
+ * includes an {@code Integer} argument being compatible with a parameter of type {@code int} or
+ * {@code long}, for example.
+ */
+ static boolean compatibleArgs(Class<?>[] paramTypes, List<Object> argValues) {
+ if (paramTypes.length != argValues.size()) {
+ return false;
+ }
+ for (int i = 0; i < paramTypes.length; i++) {
+ Class<?> paramType = paramTypes[i];
+ Object argValue = argValues.get(i);
+ if (paramType.isPrimitive()) {
+ return primitiveIsCompatible(paramType, argValue);
+ } else if (argValue != null && !paramType.isInstance(argValue)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean primitiveIsCompatible(Class<?> primitive, Object value) {
+ if (value == null || !Primitives.isWrapperType(value.getClass())) {
+ return false;
+ }
+ return primitiveTypeIsAssignmentCompatible(primitive, Primitives.unwrap(value.getClass()));
+ }
+
+ private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.of(
+ byte.class, short.class, int.class, long.class, float.class, double.class);
+ private static final int INDEX_OF_INT = NUMERICAL_PRIMITIVES.indexOf(int.class);
+
+ /**
+ * Returns true if {@code from} can be assigned to {@code to} according to
+ * <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2">Widening
+ * Primitive Conversion</a>.
+ */
+ static boolean primitiveTypeIsAssignmentCompatible(Class<?> to, Class<?> from) {
+ // To restate the JLS rules, f can be assigned to t if:
+ // - they are the same; or
+ // - f is char and t is a numeric type at least as wide as int; or
+ // - f comes before t in the order byte, short, int, long, float, double.
+ if (to == from) {
+ return true;
+ }
+ int toI = NUMERICAL_PRIMITIVES.indexOf(to);
+ if (toI < 0) {
+ return false;
+ }
+ if (from == char.class) {
+ return toI >= INDEX_OF_INT;
+ }
+ int fromI = NUMERICAL_PRIMITIVES.indexOf(from);
+ if (fromI < 0) {
+ return false;
+ }
+ return toI >= fromI;
+ }
+ }
+
+ /**
+ * Invoke the given method on the given target with the given arguments.
+ */
+ Object invokeMethod(Method method, Object target, List<Object> argValues) {
+ try {
+ return method.invoke(target, argValues.toArray());
+ } catch (InvocationTargetException e) {
+ throw evaluationException(e.getCause());
+ } catch (Exception e) {
+ throw evaluationException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/Reparser.java b/src/main/java/com/google/escapevelocity/Reparser.java
new file mode 100644
index 0000000..7f87e89
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/Reparser.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import static com.google.escapevelocity.Node.emptyNode;
+
+import com.google.escapevelocity.DirectiveNode.ForEachNode;
+import com.google.escapevelocity.DirectiveNode.IfNode;
+import com.google.escapevelocity.DirectiveNode.MacroCallNode;
+import com.google.escapevelocity.DirectiveNode.SetNode;
+import com.google.escapevelocity.TokenNode.CommentTokenNode;
+import com.google.escapevelocity.TokenNode.ElseIfTokenNode;
+import com.google.escapevelocity.TokenNode.ElseTokenNode;
+import com.google.escapevelocity.TokenNode.EndTokenNode;
+import com.google.escapevelocity.TokenNode.EofNode;
+import com.google.escapevelocity.TokenNode.ForEachTokenNode;
+import com.google.escapevelocity.TokenNode.IfOrElseIfTokenNode;
+import com.google.escapevelocity.TokenNode.IfTokenNode;
+import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode;
+import com.google.escapevelocity.TokenNode.NestedTokenNode;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * The second phase of parsing. See {@link Parser#parse()} for a description of the phases and why
+ * we need them.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+class Reparser {
+ private static final ImmutableSet<Class<? extends TokenNode>> END_SET =
+ ImmutableSet.<Class<? extends TokenNode>>of(EndTokenNode.class);
+ private static final ImmutableSet<Class<? extends TokenNode>> EOF_SET =
+ ImmutableSet.<Class<? extends TokenNode>>of(EofNode.class);
+ private static final ImmutableSet<Class<? extends TokenNode>> ELSE_ELSE_IF_END_SET =
+ ImmutableSet.<Class<? extends TokenNode>>of(
+ ElseTokenNode.class, ElseIfTokenNode.class, EndTokenNode.class);
+
+ /**
+ * The nodes that make up the input sequence. Nodes are removed one by one from this list as
+ * parsing proceeds. At any time, {@link #currentNode} is the node being examined.
+ */
+ private final ImmutableList<Node> nodes;
+
+ /**
+ * The index of the node we are currently looking at while parsing.
+ */
+ private int nodeIndex;
+
+ /**
+ * Macros are removed from the input as they are found. They do not appear in the output parse
+ * tree. Macro definitions are not executed in place but are all applied before template rendering
+ * starts. This means that a macro can be referenced before it is defined.
+ */
+ private final Map<String, Macro> macros;
+
+ Reparser(ImmutableList<Node> nodes) {
+ this(nodes, new TreeMap<>());
+ }
+
+ private Reparser(ImmutableList<Node> nodes, Map<String, Macro> macros) {
+ this.nodes = removeSpaceBeforeSet(nodes);
+ this.nodeIndex = 0;
+ this.macros = macros;
+ }
+
+ Template reparse() {
+ Node root = reparseNodes();
+ linkMacroCalls();
+ return new Template(root);
+ }
+
+ private Node reparseNodes() {
+ return parseTo(EOF_SET, new EofNode((String) null, 1));
+ }
+
+ /**
+ * Returns a copy of the given list where spaces have been moved where appropriate after {@code
+ * #set}. This hack is needed to match what appears to be special treatment in Apache Velocity of
+ * spaces before {@code #set} directives. If you have <i>thing</i> <i>whitespace</i> {@code #set},
+ * then the whitespace is deleted if the <i>thing</i> is a comment ({@code ##...\n}); a reference
+ * ({@code $x} or {@code $x.foo} etc); a macro definition; or another {@code #set}.
+ */
+ private static ImmutableList<Node> removeSpaceBeforeSet(ImmutableList<Node> nodes) {
+ assert Iterables.getLast(nodes) instanceof EofNode;
+ // Since the last node is EofNode, the i + 1 and i + 2 accesses below are safe.
+ ImmutableList.Builder<Node> newNodes = ImmutableList.builder();
+ for (int i = 0; i < nodes.size(); i++) {
+ Node nodeI = nodes.get(i);
+ newNodes.add(nodeI);
+ if (shouldDeleteSpaceBetweenThisAndSet(nodeI)
+ && isWhitespaceLiteral(nodes.get(i + 1))
+ && nodes.get(i + 2) instanceof SetNode) {
+ // Skip the space.
+ i++;
+ }
+ }
+ return newNodes.build();
+ }
+
+ private static boolean shouldDeleteSpaceBetweenThisAndSet(Node node) {
+ return node instanceof CommentTokenNode
+ || node instanceof ReferenceNode
+ || node instanceof SetNode
+ || node instanceof MacroDefinitionTokenNode;
+ }
+
+ private static boolean isWhitespaceLiteral(Node node) {
+ if (node instanceof ConstantExpressionNode) {
+ Object constant = node.evaluate(null);
+ return constant instanceof String && CharMatcher.whitespace().matchesAllOf((String) constant);
+ }
+ return false;
+ }
+
+ /**
+ * Parse subtrees until one of the token types in {@code stopSet} is encountered.
+ * If this is the top level, {@code stopSet} will include {@link EofNode} so parsing will stop
+ * when it reaches the end of the input. Otherwise, if an {@code EofNode} is encountered it is an
+ * error because we have something like {@code #if} without {@code #end}.
+ *
+ * @param stopSet the kinds of tokens that will stop the parse. For example, if we are parsing
+ * after an {@code #if}, we will stop at any of {@code #else}, {@code #elseif},
+ * or {@code #end}.
+ * @param forWhat the token that triggered this call, for example the {@code #if} whose
+ * {@code #end} etc we are looking for.
+ *
+ * @return a Node that is the concatenation of the parsed subtrees
+ */
+ private Node parseTo(Set<Class<? extends TokenNode>> stopSet, TokenNode forWhat) {
+ ImmutableList.Builder<Node> nodeList = ImmutableList.builder();
+ while (true) {
+ Node currentNode = currentNode();
+ if (stopSet.contains(currentNode.getClass())) {
+ break;
+ }
+ if (currentNode instanceof EofNode) {
+ throw new ParseException(
+ "Reached end of file while parsing " + forWhat.name(),
+ forWhat.resourceName,
+ forWhat.lineNumber);
+ }
+ Node parsed;
+ if (currentNode instanceof TokenNode) {
+ parsed = parseTokenNode();
+ } else {
+ parsed = currentNode;
+ nextNode();
+ }
+ nodeList.add(parsed);
+ }
+ return Node.cons(forWhat.resourceName, forWhat.lineNumber, nodeList.build());
+ }
+
+ private Node currentNode() {
+ return nodes.get(nodeIndex);
+ }
+
+ private Node nextNode() {
+ Node currentNode = currentNode();
+ if (currentNode instanceof EofNode) {
+ return currentNode;
+ } else {
+ nodeIndex++;
+ return currentNode();
+ }
+ }
+
+ private Node parseTokenNode() {
+ TokenNode tokenNode = (TokenNode) currentNode();
+ nextNode();
+ if (tokenNode instanceof CommentTokenNode) {
+ return emptyNode(tokenNode.resourceName, tokenNode.lineNumber);
+ } else if (tokenNode instanceof IfTokenNode) {
+ return parseIfOrElseIf((IfTokenNode) tokenNode);
+ } else if (tokenNode instanceof ForEachTokenNode) {
+ return parseForEach((ForEachTokenNode) tokenNode);
+ } else if (tokenNode instanceof NestedTokenNode) {
+ return parseNested((NestedTokenNode) tokenNode);
+ } else if (tokenNode instanceof MacroDefinitionTokenNode) {
+ return parseMacroDefinition((MacroDefinitionTokenNode) tokenNode);
+ } else {
+ throw new IllegalArgumentException(
+ "Unexpected token: " + tokenNode.name() + " on line " + tokenNode.lineNumber);
+ }
+ }
+
+ private Node parseForEach(ForEachTokenNode forEach) {
+ Node body = parseTo(END_SET, forEach);
+ nextNode(); // Skip #end
+ return new ForEachNode(
+ forEach.resourceName, forEach.lineNumber, forEach.var, forEach.collection, body);
+ }
+
+ private Node parseIfOrElseIf(IfOrElseIfTokenNode ifOrElseIf) {
+ Node truePart = parseTo(ELSE_ELSE_IF_END_SET, ifOrElseIf);
+ Node falsePart;
+ Node token = currentNode();
+ nextNode(); // Skip #else or #elseif (cond) or #end.
+ if (token instanceof EndTokenNode) {
+ falsePart = emptyNode(token.resourceName, token.lineNumber);
+ } else if (token instanceof ElseTokenNode) {
+ falsePart = parseTo(END_SET, ifOrElseIf);
+ nextNode(); // Skip #end
+ } else if (token instanceof ElseIfTokenNode) {
+ // We've seen #if (condition1) ... #elseif (condition2). currentToken is the first token
+ // after (condition2). We pretend that we've just seen #if (condition2) and parse out
+ // the remainder (which might have further #elseif and final #else). Then we pretend that
+ // we actually saw #if (condition1) ... #else #if (condition2) ...remainder ... #end #end.
+ falsePart = parseIfOrElseIf((ElseIfTokenNode) token);
+ } else {
+ throw new AssertionError(currentNode());
+ }
+ return new IfNode(
+ ifOrElseIf.resourceName, ifOrElseIf.lineNumber, ifOrElseIf.condition, truePart, falsePart);
+ }
+
+ // This is a #parse("foo.vm") directive. We've already done the first phase of parsing on the
+ // contents of foo.vm. Now we need to do the second phase, and insert the result into the
+ // reparsed nodes. We can call Reparser recursively, but we must ensure that any macros found
+ // are added to the containing Reparser's macro definitions.
+ private Node parseNested(NestedTokenNode nested) {
+ Reparser reparser = new Reparser(nested.nodes, this.macros);
+ return reparser.reparseNodes();
+ }
+
+ private Node parseMacroDefinition(MacroDefinitionTokenNode macroDefinition) {
+ Node body = parseTo(END_SET, macroDefinition);
+ nextNode(); // Skip #end
+ if (!macros.containsKey(macroDefinition.name)) {
+ Macro macro = new Macro(
+ macroDefinition.lineNumber, macroDefinition.name, macroDefinition.parameterNames, body);
+ macros.put(macroDefinition.name, macro);
+ }
+ return emptyNode(macroDefinition.resourceName, macroDefinition.lineNumber);
+ }
+
+ private void linkMacroCalls() {
+ for (Node node : nodes) {
+ if (node instanceof MacroCallNode) {
+ linkMacroCall((MacroCallNode) node);
+ }
+ }
+ }
+
+ private void linkMacroCall(MacroCallNode macroCall) {
+ Macro macro = macros.get(macroCall.name());
+ if (macro == null) {
+ throw new ParseException(
+ "#" + macroCall.name()
+ + " is neither a standard directive nor a macro that has been defined",
+ macroCall.resourceName,
+ macroCall.lineNumber);
+ }
+ if (macro.parameterCount() != macroCall.argumentCount()) {
+ throw new ParseException(
+ "Wrong number of arguments to #" + macroCall.name()
+ + ": expected " + macro.parameterCount()
+ + ", got " + macroCall.argumentCount(),
+ macroCall.resourceName,
+ macroCall.lineNumber);
+ }
+ macroCall.setMacro(macro);
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/Template.java b/src/main/java/com/google/escapevelocity/Template.java
new file mode 100644
index 0000000..6bc75c2
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/Template.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.escapevelocity.EvaluationContext.PlainEvaluationContext;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Map;
+
+/**
+ * A template expressed in EscapeVelocity, a subset of the Velocity Template Language (VTL) from
+ * Apache. The intent of this implementation is that if a template is accepted and successfully
+ * produces output, that output will be identical to what Velocity would have produced for the same
+ * template and input variables.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+// TODO(emcmanus): spell out exactly what Velocity features are unsupported.
+public class Template {
+ private final Node root;
+
+ /**
+ * Caches {@link Method} objects for public methods accessed through references. The first time
+ * we evaluate {@code $var.property} or {@code $var.method(...)} for a {@code $var} of a given
+ * class and for a given property or method signature, we'll store the resultant {@link Method}
+ * object. Every subsequent time we'll reuse that {@link Method}. The method lookup is quite slow
+ * so caching is useful. The main downside is that we may potentially hold on to {@link Method}
+ * objects that will never be used with this {@link Template} again. But in practice templates
+ * tend to be used repeatedly with the same classes.
+ */
+ private final MethodFinder methodFinder = new MethodFinder();
+
+ /**
+ * Used to resolve references to resources in the template, through {@code #parse} directives.
+ *
+ * <p>Here is an example that opens nested templates as resources relative to the calling class:
+ *
+ * <pre>{@code
+ * ResourceOpener resourceOpener = resourceName -> {
+ * InputStream inputStream = getClass().getResource(resourceName);
+ * if (inputStream == null) {
+ * throw new IOException("Unknown resource: " + resourceName);
+ * }
+ * return new BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ * };
+ * }</pre>
+ */
+ @FunctionalInterface
+ public interface ResourceOpener {
+
+ /**
+ * Returns a {@code Reader} that will be used to read the given resource, then closed.
+ *
+ * @param resourceName the name of the resource to be read. This will never be null.
+ * @return a {@code Reader} for the resource.
+ * @throws IOException if the resource cannot be opened.
+ */
+ Reader openResource(String resourceName) throws IOException;
+ }
+
+ /**
+ * Parses a VTL template from the given {@code Reader}. The template cannot reference other
+ * templates (for example with {@code #parse}). For that, use
+ * {@link #parseFrom(String, ResourceOpener)}.
+ *
+ * @param reader a Reader that will supply the text of the template. It will be closed on return
+ * from this method.
+ * @return an object representing the parsed template.
+ * @throws IOException if there is an exception reading from {@code reader}, or if the template
+ * references another template via {@code #parse}.
+ */
+ public static Template parseFrom(Reader reader) throws IOException {
+ ResourceOpener resourceOpener = resourceName -> {
+ if (resourceName == null) {
+ return reader;
+ } else {
+ throw new IOException("No ResourceOpener has been configured to read " + resourceName);
+ }
+ };
+ try {
+ return parseFrom((String) null, resourceOpener);
+ } finally {
+ reader.close();
+ }
+ }
+
+ /**
+ * Parse a VTL template of the given name using the given {@code ResourceOpener}.
+ *
+ * @param resourceName name of the resource. May be null.
+ * @param resourceOpener used to open the initial resource and resources referenced by
+ * {@code #parse} directives in the template.
+ * @return an object representing the parsed template.
+ * @throws IOException if there is an exception opening or reading from any resource.
+ */
+ public static Template parseFrom(
+ String resourceName, ResourceOpener resourceOpener) throws IOException {
+ try (Reader reader = resourceOpener.openResource(resourceName)) {
+ return new Parser(reader, resourceName, resourceOpener).parse();
+ }
+ }
+
+ Template(Node root) {
+ this.root = root;
+ }
+
+ /**
+ * Evaluate the given template with the given initial set of variables.
+ *
+ * @param vars a map where the keys are variable names and the values are the corresponding
+ * variable values. For example, if {@code "x"} maps to 23, then {@code $x} in the template
+ * will expand to 23.
+ *
+ * @return the string result of evaluating the template.
+ */
+ public String evaluate(Map<String, ?> vars) {
+ EvaluationContext evaluationContext = new PlainEvaluationContext(vars, methodFinder);
+ return String.valueOf(root.evaluate(evaluationContext));
+ }
+}
diff --git a/src/main/java/com/google/escapevelocity/TokenNode.java b/src/main/java/com/google/escapevelocity/TokenNode.java
new file mode 100644
index 0000000..971ad30
--- /dev/null
+++ b/src/main/java/com/google/escapevelocity/TokenNode.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2018 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+
+/**
+ * A parsing node that will be deleted during the construction of the parse tree, to be replaced
+ * by a higher-level construct such as {@link DirectiveNode.IfNode}. See {@link Parser#parse()}
+ * for a description of the way these tokens work.
+ *
+ * @author emcmanus@google.com (Éamonn McManus)
+ */
+abstract class TokenNode extends Node {
+ TokenNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ /**
+ * This method always throws an exception because a node like this should never be found in the
+ * final parse tree.
+ */
+ @Override Object evaluate(EvaluationContext vars) {
+ throw new UnsupportedOperationException(getClass().getName());
+ }
+
+ /**
+ * The name of the token, for use in parse error messages.
+ */
+ abstract String name();
+
+ /**
+ * A synthetic node that represents the end of the input. This node is the last one in the
+ * initial token string and also the last one in the parse tree.
+ */
+ static final class EofNode extends TokenNode {
+ EofNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ @Override
+ String name() {
+ return "end of file";
+ }
+ }
+
+ static final class EndTokenNode extends TokenNode {
+ EndTokenNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ @Override String name() {
+ return "#end";
+ }
+ }
+
+ /**
+ * A node in the parse tree representing a comment. Comments are introduced by {@code ##} and
+ * extend to the end of the line. The only reason for recording comment nodes is so that we can
+ * skip space between a comment and a following {@code #set}, to be compatible with Velocity
+ * behaviour.
+ */
+ static class CommentTokenNode extends TokenNode {
+ CommentTokenNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ @Override String name() {
+ return "##";
+ }
+ }
+
+ abstract static class IfOrElseIfTokenNode extends TokenNode {
+ final ExpressionNode condition;
+
+ IfOrElseIfTokenNode(ExpressionNode condition) {
+ super(condition.resourceName, condition.lineNumber);
+ this.condition = condition;
+ }
+ }
+
+ static final class IfTokenNode extends IfOrElseIfTokenNode {
+ IfTokenNode(ExpressionNode condition) {
+ super(condition);
+ }
+
+ @Override String name() {
+ return "#if";
+ }
+ }
+
+ static final class ElseIfTokenNode extends IfOrElseIfTokenNode {
+ ElseIfTokenNode(ExpressionNode condition) {
+ super(condition);
+ }
+
+ @Override String name() {
+ return "#elseif";
+ }
+ }
+
+ static final class ElseTokenNode extends TokenNode {
+ ElseTokenNode(String resourceName, int lineNumber) {
+ super(resourceName, lineNumber);
+ }
+
+ @Override String name() {
+ return "#else";
+ }
+ }
+
+ static final class ForEachTokenNode extends TokenNode {
+ final String var;
+ final ExpressionNode collection;
+
+ ForEachTokenNode(String var, ExpressionNode collection) {
+ super(collection.resourceName, collection.lineNumber);
+ this.var = var;
+ this.collection = collection;
+ }
+
+ @Override String name() {
+ return "#foreach";
+ }
+ }
+
+ static final class NestedTokenNode extends TokenNode {
+ final ImmutableList<Node> nodes;
+
+ NestedTokenNode(String resourceName, ImmutableList<Node> nodes) {
+ super(resourceName, 1);
+ this.nodes = nodes;
+ }
+
+ @Override String name() {
+ return "#parse(\"" + resourceName + "\")";
+ }
+ }
+
+ static final class MacroDefinitionTokenNode extends TokenNode {
+ final String name;
+ final ImmutableList<String> parameterNames;
+
+ MacroDefinitionTokenNode(
+ String resourceName, int lineNumber, String name, List<String> parameterNames) {
+ super(resourceName, lineNumber);
+ this.name = name;
+ this.parameterNames = ImmutableList.copyOf(parameterNames);
+ }
+
+ @Override String name() {
+ return "#macro(" + name + ")";
+ }
+ }
+}
diff --git a/src/test/java/com/google/escapevelocity/MethodFinderTest.java b/src/test/java/com/google/escapevelocity/MethodFinderTest.java
new file mode 100644
index 0000000..66b8948
--- /dev/null
+++ b/src/test/java/com/google/escapevelocity/MethodFinderTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2019 Google, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.escapevelocity;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MethodFinderTest {
+ @Test
+ public void visibleMethodFromClass() throws Exception {
+ Map<String, String> map = Collections.singletonMap("foo", "bar");
+ Class<?> mapClass = map.getClass();
+ assertThat(Modifier.isPublic(mapClass.getModifiers())).isFalse();
+
+ Method size = mapClass.getMethod("size");
+ Method visibleSize = MethodFinder.visibleMethod(size, mapClass);
+ assertThat(visibleSize.getDeclaringClass().isInterface()).isFalse();
+ assertThat(visibleSize.invoke(map)).isEqualTo(1);
+ }
+
+ @Test
+ public void visibleMethodFromInterface() throws Exception {
+ Map<String, String> map = ImmutableMap.of("foo", "bar");
+ Map.Entry<String, String> entry = map.entrySet().iterator().next();
+ Class<?> entryClass = entry.getClass();
+ assertThat(Modifier.isPublic(entryClass.getModifiers())).isFalse();
+
+ Method getValue = entryClass.getMethod("getValue");
+ Method visibleGetValue = MethodFinder.visibleMethod(getValue, entryClass);
+ assertThat(visibleGetValue.getDeclaringClass().isInterface()).isTrue();
+ assertThat(visibleGetValue.invoke(entry)).isEqualTo("bar");
+ }
+
+ @Test
+ public void publicMethodsWithName() {
+ List<String> list = Collections.singletonList("foo");
+ Class<?> listClass = list.getClass();
+ assertThat(Modifier.isPublic(listClass.getModifiers())).isFalse();
+
+ MethodFinder methodFinder = new MethodFinder();
+ Set<Method> methods = methodFinder.publicMethodsWithName(listClass, "remove");
+ // This should find at least remove(int) and remove(Object).
+ assertThat(methods.size()).isAtLeast(2);
+ assertThat(methods.stream().map(Method::getName).collect(toSet())).containsExactly("remove");
+ assertThat(methods.stream().allMatch(MethodFinderTest::isPublic)).isTrue();
+
+ // We should cache the result, meaning we get back the same result if we ask a second time.
+ Set<Method> methods2 = methodFinder.publicMethodsWithName(listClass, "remove");
+ assertThat(methods2).isSameInstanceAs(methods);
+ }
+
+ @Test
+ public void publicMethodsWithName_Nonexistent() {
+ List<String> list = Collections.singletonList("foo");
+ Class<?> listClass = list.getClass();
+ assertThat(Modifier.isPublic(listClass.getModifiers())).isFalse();
+
+ MethodFinder methodFinder = new MethodFinder();
+ Set<Method> methods = methodFinder.publicMethodsWithName(listClass, "nonexistentMethod");
+ assertThat(methods).isEmpty();
+ }
+
+ private static boolean isPublic(Method method) {
+ return Modifier.isPublic(method.getModifiers())
+ && Modifier.isPublic(method.getDeclaringClass().getModifiers());
+ }
+}
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..b1759bd
--- /dev/null
+++ b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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 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 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(actual).isEqualTo(expected);
+ }
+ }
+ }
+
+ @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..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");
+ }
+ }
+}