diff options
author | Éamonn McManus <eamonn@mcmanus.net> | 2017-12-27 16:54:45 -0800 |
---|---|---|
committer | Éamonn McManus <eamonn@mcmanus.net> | 2017-12-27 16:54:45 -0800 |
commit | d68715b3e62b4f1b8ebd7fa051004934a2ba913d (patch) | |
tree | 160d1cdf81b4b61c3e27d74fff3e6f4a74ed675a | |
download | escapevelocity-d68715b3e62b4f1b8ebd7fa051004934a2ba913d.tar.gz |
Initial version.
This is forked from the code built in to AutoValue, with the following non-trivial changes:
(1) Package changed from com.google.auto.value.processor.escapevelocity to com.google.escapevelocity.
(2) New pom.xml.
(3) Code rewritten to remove Guava dependency, so no shading or diamond dependency problems.
25 files changed, 4863 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. @@ -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. @@ -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..839c002 --- /dev/null +++ b/README.md @@ -0,0 +1,344 @@ +# 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. + +<!-- MOE:begin_strip --> +[TOC] +<!-- MOE:end_strip --> + +## 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. + +## <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 @@ -0,0 +1,98 @@ +<?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> + + <groupId>com.google.escapevelocity</groupId> + <artifactId>escapevelocity</artifactId> + <version>0.9-SNAPSHOT</version> + <name>EscapeVelocity</name> + <description> + A reimplementation of a subset of the Apache Velocity templating system. + </description> + + <!-- TODO(emcmanus) + <scm> + <url>http://github.com/google/auto</url> + <connection>scm:git:git://github.com/google/auto.git</connection> + <developerConnection>scm:git:ssh://git@github.com/google/auto.git</developerConnection> + <tag>HEAD</tag> + </scm> + --> + + <dependencies> + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + <version>23.5-jre</version> + <scope>test</scope> + </dependency> + <!-- test dependencies --> + <dependency> + <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.36</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.7</source> + <target>1.7</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..a4dfe17 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ConstantExpressionNode.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +/** + * A node in the parse tree representing a constant value. Evaluating the node yields the constant + * value. Instances of this class are used both in expressions, like the {@code 23} in + * {@code #set ($x = 23)}, and for literal text in templates. In the template... + * <pre>{@code + * abc#{if}($x == 5)def#{end}xyz + * }</pre> + * ...each of the strings {@code abc}, {@code def}, {@code xyz} is represented by an instance of + * this class that {@linkplain #evaluate evaluates} to that string, and the value {@code 5} is + * represented by an instance of this class that evaluates to the integer 5. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ConstantExpressionNode extends ExpressionNode { + private final Object value; + + ConstantExpressionNode(String resourceName, int lineNumber, Object value) { + super(resourceName, lineNumber); + this.value = value; + } + + @Override + Object evaluate(EvaluationContext context) { + return value; + } +} diff --git a/src/main/java/com/google/escapevelocity/DirectiveNode.java b/src/main/java/com/google/escapevelocity/DirectiveNode.java new file mode 100644 index 0000000..cf33f55 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/DirectiveNode.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +/** + * A node in the parse tree that is a directive such as {@code #set ($x = $y)} + * or {@code #if ($x) y #end}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class DirectiveNode extends Node { + DirectiveNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * A node in the parse tree representing a {@code #set} construct. Evaluating + * {@code #set ($x = 23)} will set {@code $x} to the value 23. It does not in itself produce + * any text in the output. + * + * <p>Velocity supports setting values within arrays or collections, with for example + * {@code $set ($x[$i] = $y)}. That is not currently supported here. + */ + static class SetNode extends DirectiveNode { + private final String var; + private final Node expression; + + SetNode(String var, Node expression) { + super(expression.resourceName, expression.lineNumber); + this.var = var; + this.expression = expression; + } + + @Override + Object evaluate(EvaluationContext context) { + context.setVar(var, expression.evaluate(context)); + return ""; + } + } + + /** + * A node in the parse tree representing an {@code #if} construct. All instances of this class + * have a <i>true</i> subtree and a <i>false</i> subtree. For a plain {@code #if (cond) body + * #end}, the false subtree will be empty. For {@code #if (cond1) body1 #elseif (cond2) body2 + * #else body3 #end}, the false subtree will contain a nested {@code IfNode}, as if {@code #else + * #if} had been used instead of {@code #elseif}. + */ + static class IfNode extends DirectiveNode { + private final ExpressionNode condition; + private final Node truePart; + private final Node falsePart; + + IfNode( + String resourceName, + int lineNumber, + ExpressionNode condition, + Node trueNode, + Node falseNode) { + super(resourceName, lineNumber); + this.condition = condition; + this.truePart = trueNode; + this.falsePart = falseNode; + } + + @Override Object evaluate(EvaluationContext context) { + Node branch = condition.isDefinedAndTrue(context) ? truePart : falsePart; + return branch.evaluate(context); + } + } + + /** + * A node in the parse tree representing a {@code #foreach} construct. While evaluating + * {@code #foreach ($x in $things)}, {$code $x} will be set to each element of {@code $things} in + * turn. Once the loop completes, {@code $x} will go back to whatever value it had before, which + * might be undefined. During loop execution, the variable {@code $foreach} is also defined. + * Velocity defines a number of properties in this variable, but here we only support + * {@code $foreach.hasNext}. + */ + static class ForEachNode extends DirectiveNode { + private final String var; + private final ExpressionNode collection; + private final Node body; + + ForEachNode(String resourceName, int lineNumber, String var, ExpressionNode in, Node body) { + super(resourceName, lineNumber); + this.var = var; + this.collection = in; + this.body = body; + } + + @Override + Object evaluate(EvaluationContext context) { + Object collectionValue = collection.evaluate(context); + Iterable<?> iterable; + if (collectionValue instanceof Iterable<?>) { + iterable = (Iterable<?>) collectionValue; + } else if (collectionValue instanceof Object[]) { + iterable = Arrays.asList((Object[]) collectionValue); + } else if (collectionValue instanceof Map<?, ?>) { + iterable = ((Map<?, ?>) collectionValue).values(); + } else { + throw evaluationException("Not iterable: " + collectionValue); + } + Runnable undo = context.setVar(var, null); + StringBuilder sb = new StringBuilder(); + Iterator<?> it = iterable.iterator(); + Runnable undoForEach = context.setVar("foreach", new ForEachVar(it)); + while (it.hasNext()) { + context.setVar(var, it.next()); + sb.append(body.evaluate(context)); + } + undoForEach.run(); + undo.run(); + return sb.toString(); + } + + /** + * This class is the type of the variable {@code $foreach} that is defined within + * {@code #foreach} loops. Its {@link #getHasNext()} method means that we can write + * {@code #if ($foreach.hasNext)}. + */ + private static class ForEachVar { + private final Iterator<?> iterator; + + ForEachVar(Iterator<?> iterator) { + this.iterator = iterator; + } + + public boolean getHasNext() { + return iterator.hasNext(); + } + } + } + + /** + * A node in the parse tree representing a macro call. If the template contains a definition like + * {@code #macro (mymacro $x $y) ... #end}, then a call of that macro looks like + * {@code #mymacro (xvalue yvalue)}. The call is represented by an instance of this class. The + * definition itself does not appear in the parse tree. + * + * <p>Evaluating a macro involves temporarily setting the parameter variables ({@code $x $y} in + * the example) to thunks representing the argument expressions, evaluating the macro body, and + * restoring any previous values that the parameter variables had. + */ + static class MacroCallNode extends DirectiveNode { + private final String name; + private final ImmutableList<Node> thunks; + private Macro macro; + + MacroCallNode( + String resourceName, + int lineNumber, + String name, + ImmutableList<Node> argumentNodes) { + super(resourceName, lineNumber); + this.name = name; + this.thunks = argumentNodes; + } + + String name() { + return name; + } + + int argumentCount() { + return thunks.size(); + } + + void setMacro(Macro macro) { + this.macro = macro; + } + + @Override + Object evaluate(EvaluationContext context) { + assert macro != null : "Macro should have been linked: #" + name; + return macro.evaluate(context, thunks); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/EvaluationContext.java b/src/main/java/com/google/escapevelocity/EvaluationContext.java new file mode 100644 index 0000000..43b7868 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationContext.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.Map; +import java.util.TreeMap; + +/** + * The context of a template evaluation. This consists of the template variables and the template + * macros. The template variables start with the values supplied by the evaluation call, and can + * be changed by {@code #set} directives and during the execution of {@code #foreach} and macro + * calls. The macros are extracted from the template during parsing and never change thereafter. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +interface EvaluationContext { + Object getVar(String var); + + boolean varIsDefined(String var); + + /** + * Sets the given variable to the given value. + * + * @return a Runnable that will restore the variable to the value it had before. If the variable + * was undefined before this method was executed, the Runnable will make it undefined again. + * This allows us to restore the state of {@code $x} after {@code #foreach ($x in ...)}. + */ + Runnable setVar(final String var, Object value); + + class PlainEvaluationContext implements EvaluationContext { + private final Map<String, Object> vars; + + PlainEvaluationContext(Map<String, ?> vars) { + this.vars = new TreeMap<String, Object>(vars); + } + + @Override + public Object getVar(String var) { + return vars.get(var); + } + + @Override + public boolean varIsDefined(String var) { + return vars.containsKey(var); + } + + @Override + public Runnable setVar(final String var, Object value) { + Runnable undo; + if (vars.containsKey(var)) { + final Object oldValue = vars.get(var); + undo = new Runnable() { + @Override public void run() { + vars.put(var, oldValue); + } + }; + } else { + undo = new Runnable() { + @Override public void run() { + vars.remove(var); + } + }; + } + vars.put(var, value); + return undo; + } + } +} diff --git a/src/main/java/com/google/escapevelocity/EvaluationException.java b/src/main/java/com/google/escapevelocity/EvaluationException.java new file mode 100644 index 0000000..67aa15c --- /dev/null +++ b/src/main/java/com/google/escapevelocity/EvaluationException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +/** + * An exception that occurred while evaluating a template, such as an undefined variable reference + * or a division by zero. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +public class EvaluationException extends RuntimeException { + private static final long serialVersionUID = 1; + + EvaluationException(String message) { + super(message); + } + + EvaluationException(String message, Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/google/escapevelocity/ExpressionNode.java b/src/main/java/com/google/escapevelocity/ExpressionNode.java new file mode 100644 index 0000000..4ee29c5 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ExpressionNode.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import com.google.escapevelocity.Parser.Operator; + +/** + * A node in the parse tree representing an expression. Expressions appear inside directives, + * specifically {@code #set}, {@code #if}, {@code #foreach}, and macro calls. Expressions can + * also appear inside indices in references, like {@code $x[$i]}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class ExpressionNode extends Node { + ExpressionNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * True if evaluating this expression yields a value that is considered true by Velocity's + * <a href="http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html#Conditionals"> + * rules</a>. A value is false if it is null or equal to Boolean.FALSE. + * Every other value is true. + * + * <p>Note that the text at the similar link + * <a href="http://velocity.apache.org/engine/devel/user-guide.html#Conditionals">here</a> + * states that empty collections and empty strings are also considered false, but that is not + * true. + */ + boolean isTrue(EvaluationContext context) { + Object value = evaluate(context); + if (value instanceof Boolean) { + return (Boolean) value; + } else { + return value != null; + } + } + + /** + * True if this is a defined value and it evaluates to true. This is the same as {@link #isTrue} + * except that it is allowed for this to be undefined variable, in which it evaluates to false. + * The method is overridden for plain references so that undefined is the same as false. + * The reason is to support Velocity's idiom {@code #if ($var)}, where it is not an error + * if {@code $var} is undefined. + */ + boolean isDefinedAndTrue(EvaluationContext context) { + return isTrue(context); + } + + /** + * The integer result of evaluating this expression. + * + * @throws EvaluationException if evaluating the expression produces an exception, or if it + * yields a value that is not an integer. + */ + int intValue(EvaluationContext context) { + Object value = evaluate(context); + if (!(value instanceof Integer)) { + throw evaluationException("Arithemtic is only available on integers, not " + show(value)); + } + return (Integer) value; + } + + /** + * Returns a string representing the given value, for use in error messages. The string + * includes both the value's {@code toString()} and its type. + */ + private static String show(Object value) { + if (value == null) { + return "null"; + } else { + return value + " (a " + value.getClass().getName() + ")"; + } + } + + /** + * Represents all binary expressions. In {@code #set ($a = $b + $c)}, this will be the type + * of the node representing {@code $b + $c}. + */ + static class BinaryExpressionNode extends ExpressionNode { + final ExpressionNode lhs; + final Operator op; + final ExpressionNode rhs; + + BinaryExpressionNode(ExpressionNode lhs, Operator op, ExpressionNode rhs) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.op = op; + this.rhs = rhs; + } + + @Override Object evaluate(EvaluationContext context) { + switch (op) { + case OR: + return lhs.isTrue(context) || rhs.isTrue(context); + case AND: + return lhs.isTrue(context) && rhs.isTrue(context); + case EQUAL: + return equal(context); + case NOT_EQUAL: + return !equal(context); + default: // fall out + } + int lhsInt = lhs.intValue(context); + int rhsInt = rhs.intValue(context); + switch (op) { + case LESS: + return lhsInt < rhsInt; + case LESS_OR_EQUAL: + return lhsInt <= rhsInt; + case GREATER: + return lhsInt > rhsInt; + case GREATER_OR_EQUAL: + return lhsInt >= rhsInt; + case PLUS: + return lhsInt + rhsInt; + case MINUS: + return lhsInt - rhsInt; + case TIMES: + return lhsInt * rhsInt; + case DIVIDE: + return lhsInt / rhsInt; + case REMAINDER: + return lhsInt % rhsInt; + default: + throw new AssertionError(op); + } + } + + /** + * Returns true if {@code lhs} and {@code rhs} are equal according to Velocity. + * + * <p>Velocity's <a + * href="http://velocity.apache.org/engine/releases/velocity-1.7/vtl-reference-guide.html#aifelseifelse_-_Output_conditional_on_truth_of_statements">definition + * of equality</a> differs depending on whether the objects being compared are of the same + * class. If so, equality comes from {@code Object.equals} as you would expect. But if they + * are not of the same class, they are considered equal if their {@code toString()} values are + * equal. This means that integer 123 equals long 123L and also string {@code "123"}. It also + * means that equality isn't always transitive. For example, two StringBuilder objects each + * containing {@code "123"} will not compare equal, even though the string {@code "123"} + * compares equal to each of them. + */ + private boolean equal(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + Object rhsValue = rhs.evaluate(context); + if (lhsValue == rhsValue) { + return true; + } + if (lhsValue == null || rhsValue == null) { + return false; + } + if (lhsValue.getClass().equals(rhsValue.getClass())) { + return lhsValue.equals(rhsValue); + } + // Funky equals behaviour specified by Velocity. + return lhsValue.toString().equals(rhsValue.toString()); + } + } + + /** + * A node in the parse tree representing an expression like {@code !$a}. + */ + static class NotExpressionNode extends ExpressionNode { + private final ExpressionNode expr; + + NotExpressionNode(ExpressionNode expr) { + super(expr.resourceName, expr.lineNumber); + this.expr = expr; + } + + @Override Object evaluate(EvaluationContext context) { + return !expr.isTrue(context); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/ImmutableAsciiSet.java b/src/main/java/com/google/escapevelocity/ImmutableAsciiSet.java new file mode 100644 index 0000000..96a126c --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableAsciiSet.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.AbstractSet; +import java.util.BitSet; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * An immutable set of ASCII characters. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableAsciiSet extends AbstractSet<Integer> { + private final BitSet bits; + + ImmutableAsciiSet(BitSet bits) { + this.bits = bits; + } + + static ImmutableAsciiSet of(char c) { + return ofRange(c, c); + } + + static ImmutableAsciiSet ofRange(char from, char to) { + if (from > to) { + throw new IllegalArgumentException("from > to"); + } + if (to >= 128) { + throw new IllegalArgumentException("Not ASCII"); + } + BitSet bits = new BitSet(); + bits.set(from, to + 1); + return new ImmutableAsciiSet(bits); + } + + ImmutableAsciiSet union(ImmutableAsciiSet that) { + BitSet union = (BitSet) bits.clone(); + union.or(that.bits); + return new ImmutableAsciiSet(union); + } + + @Override + public boolean contains(Object o) { + int i = -1; + if (o instanceof Character) { + i = (Character) o; + } else if (o instanceof Integer) { + i = (Integer) o; + } + return contains(i); + } + + boolean contains(int i) { + if (i < 0) { + return false; + } else { + return bits.get(i); + } + } + + @Override + public Iterator<Integer> iterator() { + return new Iterator<Integer>() { + private int index; + + @Override + public boolean hasNext() { + return bits.nextSetBit(index) >= 0; + } + + @Override + public Integer next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + int next = bits.nextSetBit(index); + index = next + 1; + return next; + } + }; + } + + @Override + public int size() { + return bits.cardinality(); + } +} diff --git a/src/main/java/com/google/escapevelocity/ImmutableList.java b/src/main/java/com/google/escapevelocity/ImmutableList.java new file mode 100644 index 0000000..0b903f7 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableList.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * An immutable list. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableList<E> extends AbstractList<E> { + private static final ImmutableList<?> EMPTY = new ImmutableList<>(new Object[0]); + + private final E[] elements; + + private ImmutableList(E[] elements) { + this.elements = elements; + } + + @Override + public Iterator<E> iterator() { + return Arrays.asList(elements).iterator(); + } + + @Override + public E get(int index) { + if (index < 0 || index >= elements.length) { + throw new IndexOutOfBoundsException(String.valueOf(index)); + } + return elements[index]; + } + + @Override + public int size() { + return elements.length; + } + + static <E> ImmutableList<E> of() { + @SuppressWarnings("unchecked") + ImmutableList<E> empty = (ImmutableList<E>) EMPTY; + return empty; + } + + @SafeVarargs + static <E> ImmutableList<E> of(E... elements) { + return new ImmutableList<>(elements.clone()); + } + + static <E> ImmutableList<E> copyOf(List<E> list) { + @SuppressWarnings("unchecked") + E[] elements = (E[]) new Object[list.size()]; + list.toArray(elements); + return new ImmutableList<>(elements); + } + + static <E> Builder<E> builder() { + return new Builder<E>(); + } + + static class Builder<E> { + private final List<E> list = new ArrayList<>(); + + void add(E element) { + list.add(element); + } + + ImmutableList<E> build() { + if (list.isEmpty()) { + return ImmutableList.of(); + } + @SuppressWarnings("unchecked") + E[] elements = (E[]) new Object[list.size()]; + list.toArray(elements); + return new ImmutableList<>(elements); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/ImmutableSet.java b/src/main/java/com/google/escapevelocity/ImmutableSet.java new file mode 100644 index 0000000..f4e8e9f --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ImmutableSet.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.AbstractSet; +import java.util.Arrays; +import java.util.Iterator; + +/** + * An immutable set. This implementation is only suitable for sets with a small number of elements. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class ImmutableSet<E> extends AbstractSet<E> { + private final E[] elements; + + private ImmutableSet(E[] elements) { + this.elements = elements; + } + + @Override + public Iterator<E> iterator() { + return Arrays.asList(elements).iterator(); + } + + @Override + public int size() { + return elements.length; + } + + @SafeVarargs + static <E> ImmutableSet<E> of(E... elements) { + int len = elements.length; + for (int i = 0; i < len - 1; i++) { + for (int j = len - 1; j > i; j--) { + if (elements[i].equals(elements[j])) { + // We want to exclude elements[j] from the final set. We can do that by copying the + // current last element in place of j (this might be j itself) and then reducing the + // size of the set. + elements[j] = elements[len - 1]; + len--; + } + } + } + return new ImmutableSet<>(Arrays.copyOf(elements, len)); + } +} diff --git a/src/main/java/com/google/escapevelocity/Macro.java b/src/main/java/com/google/escapevelocity/Macro.java new file mode 100644 index 0000000..151ded2 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Macro.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +/** + * A macro definition. Macros appear in templates using the syntax {@code #macro (m $x $y) ... #end} + * and each one produces an instance of this class. Evaluating a macro involves setting the + * parameters (here {$x $y)} and evaluating the macro body. Macro arguments are call-by-name, which + * means that we need to set each parameter variable to the node in the parse tree that corresponds + * to it, and arrange for that node to be evaluated when the variable is actually referenced. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Macro { + private final int definitionLineNumber; + private final String name; + private final ImmutableList<String> parameterNames; + private final Node body; + + Macro(int definitionLineNumber, String name, List<String> parameterNames, Node body) { + this.definitionLineNumber = definitionLineNumber; + this.name = name; + this.parameterNames = ImmutableList.copyOf(parameterNames); + this.body = body; + } + + String name() { + return name; + } + + int parameterCount() { + return parameterNames.size(); + } + + Object evaluate(EvaluationContext context, List<Node> thunks) { + try { + assert thunks.size() == parameterNames.size() : "Argument mistmatch for " + name; + Map<String, Node> parameterThunks = new LinkedHashMap<>(); + for (int i = 0; i < parameterNames.size(); i++) { + parameterThunks.put(parameterNames.get(i), thunks.get(i)); + } + EvaluationContext newContext = new MacroEvaluationContext(parameterThunks, context); + return body.evaluate(newContext); + } catch (EvaluationException e) { + EvaluationException newException = new EvaluationException( + "In macro #" + name + " defined on line " + definitionLineNumber + ": " + e.getMessage()); + newException.setStackTrace(e.getStackTrace()); + throw e; + } + } + + /** + * The context for evaluation within macros. This wraps an existing {@code EvaluationContext} + * but intercepts reads of the macro's parameters so that they result in a call-by-name evaluation + * of whatever was passed as the parameter. For example, if you write... + * <pre>{@code + * #macro (mymacro $x) + * $x $x + * #end + * #mymacro($foo.bar(23)) + * }</pre> + * ...then the {@code #mymacro} call will result in {@code $foo.bar(23)} being evaluated twice, + * once for each time {@code $x} appears. The way this works is that {@code $x} is a <i>thunk</i>. + * Historically a thunk is a piece of code to evaluate an expression in the context where it + * occurs, for call-by-name procedures as in Algol 60. Here, it is not exactly a piece of code, + * but it has the same responsibility. + */ + static class MacroEvaluationContext implements EvaluationContext { + private final Map<String, Node> parameterThunks; + private final EvaluationContext originalEvaluationContext; + + MacroEvaluationContext( + Map<String, Node> parameterThunks, EvaluationContext originalEvaluationContext) { + this.parameterThunks = parameterThunks; + this.originalEvaluationContext = originalEvaluationContext; + } + + @Override + public Object getVar(String var) { + Node thunk = parameterThunks.get(var); + if (thunk == null) { + return originalEvaluationContext.getVar(var); + } else { + // Evaluate the thunk in the context where it appeared, not in this context. Otherwise + // if you pass $x to a parameter called $x you would get an infinite recursion. Likewise + // if you had #macro(mymacro $x $y) and a call #mymacro($y 23), you would expect that $x + // would expand to whatever $y meant at the call site, rather than to the value of the $y + // parameter. + return thunk.evaluate(originalEvaluationContext); + } + } + + @Override + public boolean varIsDefined(String var) { + return parameterThunks.containsKey(var) || originalEvaluationContext.varIsDefined(var); + } + + @Override + public Runnable setVar(final String var, Object value) { + // Copy the behaviour that #set will shadow a macro parameter, even though the Velocity peeps + // seem to agree that that is not good. + final Node thunk = parameterThunks.get(var); + if (thunk == null) { + return originalEvaluationContext.setVar(var, value); + } else { + parameterThunks.remove(var); + final Runnable originalUndo = originalEvaluationContext.setVar(var, value); + return new Runnable() { + @Override + public void run() { + originalUndo.run(); + parameterThunks.put(var, thunk); + } + }; + } + } + } +} diff --git a/src/main/java/com/google/escapevelocity/Node.java b/src/main/java/com/google/escapevelocity/Node.java new file mode 100644 index 0000000..eca745f --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Node.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +/** + * A node in the parse tree. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class Node { + final String resourceName; + final int lineNumber; + + Node(String resourceName, int lineNumber) { + this.resourceName = resourceName; + this.lineNumber = lineNumber; + } + + /** + * Returns the result of evaluating this node in the given context. This result may be used as + * part of a further operation, for example evaluating {@code 2 + 3} to 5 in order to set + * {@code $x} to 5 in {@code #set ($x = 2 + 3)}. Or it may be used directly as part of the + * template output, for example evaluating replacing {@code name} by {@code Fred} in + * {@code My name is $name.}. + */ + abstract Object evaluate(EvaluationContext context); + + private String where() { + String where = "In expression on line " + lineNumber; + if (resourceName != null) { + where += " of " + resourceName; + } + return where; + } + + EvaluationException evaluationException(String message) { + return new EvaluationException(where() + ": " + message); + } + + EvaluationException evaluationException(Throwable cause) { + return new EvaluationException(where() + ": " + cause, cause); + } + + /** + * Returns an empty node in the parse tree. This is used for example to represent the trivial + * "else" part of an {@code #if} that does not have an explicit {@code #else}. + */ + static Node emptyNode(String resourceName, int lineNumber) { + return new Cons(resourceName, lineNumber, ImmutableList.<Node>of()); + } + + + /** + * Create a new parse tree node that is the concatenation of the given ones. Evaluating the + * new node produces the same string as evaluating each of the given nodes and concatenating the + * result. + */ + static Node cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) { + return new Cons(resourceName, lineNumber, nodes); + } + + private static final class Cons extends Node { + private final ImmutableList<Node> nodes; + + Cons(String resourceName, int lineNumber, ImmutableList<Node> nodes) { + super(resourceName, lineNumber); + this.nodes = nodes; + } + + @Override Object evaluate(EvaluationContext context) { + StringBuilder sb = new StringBuilder(); + for (Node node : nodes) { + sb.append(node.evaluate(context)); + } + return sb.toString(); + } + } +} diff --git a/src/main/java/com/google/escapevelocity/ParseException.java b/src/main/java/com/google/escapevelocity/ParseException.java new file mode 100644 index 0000000..241a192 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ParseException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +/** + * An exception that occurred while parsing a template. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +public class ParseException extends RuntimeException { + private static final long serialVersionUID = 1; + + ParseException(String message, String resourceName, int lineNumber) { + super(message + ", " + where(resourceName, lineNumber)); + } + + ParseException(String message, String resourceName, int lineNumber, String context) { + super(message + ", " + where(resourceName, lineNumber) + ", at text starting: " + context); + } + + private static String where(String resourceName, int lineNumber) { + if (resourceName == null) { + return "on line " + lineNumber; + } else { + return "on line " + lineNumber + " of " + resourceName; + } + } +} diff --git a/src/main/java/com/google/escapevelocity/Parser.java b/src/main/java/com/google/escapevelocity/Parser.java new file mode 100644 index 0000000..9982be3 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Parser.java @@ -0,0 +1,963 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import com.google.escapevelocity.DirectiveNode.SetNode; +import com.google.escapevelocity.ExpressionNode.BinaryExpressionNode; +import com.google.escapevelocity.ExpressionNode.NotExpressionNode; +import com.google.escapevelocity.ReferenceNode.IndexReferenceNode; +import com.google.escapevelocity.ReferenceNode.MemberReferenceNode; +import com.google.escapevelocity.ReferenceNode.MethodReferenceNode; +import com.google.escapevelocity.ReferenceNode.PlainReferenceNode; +import com.google.escapevelocity.TokenNode.CommentTokenNode; +import com.google.escapevelocity.TokenNode.ElseIfTokenNode; +import com.google.escapevelocity.TokenNode.ElseTokenNode; +import com.google.escapevelocity.TokenNode.EndTokenNode; +import com.google.escapevelocity.TokenNode.EofNode; +import com.google.escapevelocity.TokenNode.ForEachTokenNode; +import com.google.escapevelocity.TokenNode.IfTokenNode; +import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode; +import com.google.escapevelocity.TokenNode.NestedTokenNode; +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A parser that reads input from the given {@link Reader} and parses it to produce a + * {@link Template}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Parser { + private static final int EOF = -1; + + private final LineNumberReader reader; + private final String resourceName; + private final Template.ResourceOpener resourceOpener; + + /** + * The invariant of this parser is that {@code c} is always the next character of interest. + * This means that we never have to "unget" a character by reading too far. For example, after + * we parse an integer, {@code c} will be the first character after the integer, which is exactly + * the state we will be in when there are no more digits. + */ + private int c; + + Parser(Reader reader, String resourceName, Template.ResourceOpener resourceOpener) + throws IOException { + this.reader = new LineNumberReader(reader); + this.reader.setLineNumber(1); + next(); + this.resourceName = resourceName; + this.resourceOpener = resourceOpener; + } + + /** + * Parse the input completely to produce a {@link Template}. + * + * <p>Parsing happens in two phases. First, we parse a sequence of "tokens", where tokens include + * entire references such as <pre> + * ${x.foo()[23]} + * </pre>or entire directives such as<pre> + * #set ($x = $y + $z) + * </pre>But tokens do not span complex constructs. For example,<pre> + * #if ($x == $y) something #end + * </pre>is three tokens:<pre> + * #if ($x == $y) + * (literal text " something ") + * #end + * </pre> + * + * <p>The second phase then takes the sequence of tokens and constructs a parse tree out of it. + * Some nodes in the parse tree will be unchanged from the token sequence, such as the <pre> + * ${x.foo()[23]} + * #set ($x = $y + $z) + * </pre> examples above. But a construct such as the {@code #if ... #end} mentioned above will + * become a single IfNode in the parse tree in the second phase. + * + * <p>The main reason for this approach is that Velocity has two kinds of lexical contexts. At the + * top level, there can be arbitrary literal text; references like <code>${x.foo()}</code>; and + * directives like {@code #if} or {@code #set}. Inside the parentheses of a directive, however, + * neither arbitrary text nor directives can appear, but expressions can, so we need to tokenize + * the inside of <pre> + * #if ($x == $a + $b) + * </pre> as the five tokens "$x", "==", "$a", "+", "$b". Rather than having a classical + * parser/lexer combination, where the lexer would need to switch between these two modes, we + * replace the lexer with an ad-hoc parser that is the first phase described above, and we + * define a simple parser over the resultant tokens that is the second phase. + */ + Template parse() throws IOException { + ImmutableList<Node> tokens = parseTokens(); + return new Reparser(tokens).reparse(); + } + + private ImmutableList<Node> parseTokens() throws IOException { + ImmutableList.Builder<Node> tokens = ImmutableList.builder(); + Node token; + do { + token = parseNode(); + tokens.add(token); + } while (!(token instanceof EofNode)); + return tokens.build(); + } + + private int lineNumber() { + return reader.getLineNumber(); + } + + /** + * Gets the next character from the reader and assigns it to {@code c}. If there are no more + * characters, sets {@code c} to {@link #EOF} if it is not already. + */ + private void next() throws IOException { + if (c != EOF) { + c = reader.read(); + } + } + + /** + * If {@code c} is a space character, keeps reading until {@code c} is a non-space character or + * there are no more characters. + */ + private void skipSpace() throws IOException { + while (Character.isWhitespace(c)) { + next(); + } + } + + /** + * Gets the next character from the reader, and if it is a space character, keeps reading until + * a non-space character is found. + */ + private void nextNonSpace() throws IOException { + next(); + skipSpace(); + } + + /** + * Skips any space in the reader, and then throws an exception if the first non-space character + * found is not the expected one. Sets {@code c} to the first character after that expected one. + */ + private void expect(char expected) throws IOException { + skipSpace(); + if (c == expected) { + next(); + } else { + throw parseException("Expected " + expected); + } + } + + /** + * Parses a single node from the reader, as part of the first parsing phase. + * <pre>{@code + * <template> -> <empty> | + * <directive> <template> | + * <non-directive> <template> + * }</pre> + */ + private Node parseNode() throws IOException { + if (c == '#') { + next(); + if (c == '#') { + return parseComment(); + } else if (isAsciiLetter(c) || c == '{') { + return parseDirective(); + } else if (c == '[') { + return parseHashSquare(); + } else { + // For consistency with Velocity, we treat # not followed by # or a letter as a plain + // character, and we treat #$foo as a literal # followed by the reference $foo. + // But the # is its own ConstantExpressionNode; we don't try to merge it with adjacent text. + return new ConstantExpressionNode(resourceName, lineNumber(), "#"); + } + } + if (c == EOF) { + return new EofNode(resourceName, lineNumber()); + } + return parseNonDirective(); + } + + private Node parseHashSquare() throws IOException { + // We've just seen #[ which might be the start of a #[[quoted block]]#. If the next character + // is not another [ then it's not a quoted block, but it *is* a literal #[ followed by whatever + // that next character is. + assert c == '['; + next(); + if (c != '[') { + return new ConstantExpressionNode(resourceName, lineNumber(), "#["); + } + next(); + StringBuilder sb = new StringBuilder(); + while (true) { + if (c == EOF) { + throw parseException("Unterminated #[[ - did not see matching ]]#"); + } + if (c == '#') { + // This might be the last character of ]]# or it might just be a random #. + int len = sb.length(); + if (len > 1 && sb.charAt(len - 1) == ']' && sb.charAt(len - 2) == ']') { + next(); + break; + } + } + sb.append((char) c); + next(); + } + String quoted = sb.substring(0, sb.length() - 2); + return new ConstantExpressionNode(resourceName, lineNumber(), quoted); + } + + /** + * Parses a single non-directive node from the reader. + * <pre>{@code + * <non-directive> -> <reference> | + * <text containing neither $ nor #> + * }</pre> + */ + private Node parseNonDirective() throws IOException { + if (c == '$') { + next(); + if (isAsciiLetter(c) || c == '{') { + return parseReference(); + } else { + return parsePlainText('$'); + } + } else { + int firstChar = c; + next(); + return parsePlainText(firstChar); + } + } + + /** + * Parses a single directive token from the reader. Directives can be spelled with or without + * braces, for example {@code #if} or {@code #{if}}. We omit the brace spelling in the productions + * here: <pre>{@code + * <directive> -> <if-token> | + * <else-token> | + * <elseif-token> | + * <end-token> | + * <foreach-token> | + * <set-token> | + * <parse-token> | + * <macro-token> | + * <macro-call> | + * <comment> + * }</pre> + */ + private Node parseDirective() throws IOException { + String directive; + if (c == '{') { + next(); + directive = parseId("Directive inside #{...}"); + expect('}'); + } else { + directive = parseId("Directive"); + } + Node node; + switch (directive) { + case "end": + node = new EndTokenNode(resourceName, lineNumber()); + break; + case "if": + case "elseif": + node = parseIfOrElseIf(directive); + break; + case "else": + node = new ElseTokenNode(resourceName, lineNumber()); + break; + case "foreach": + node = parseForEach(); + break; + case "set": + node = parseSet(); + break; + case "parse": + node = parseParse(); + break; + case "macro": + node = parseMacroDefinition(); + break; + default: + node = parsePossibleMacroCall(directive); + } + // Velocity skips a newline after any directive. + // TODO(emcmanus): in fact it also skips space before the newline, which should be implemented. + if (c == '\n') { + next(); + } + return node; + } + + /** + * Parses the condition following {@code #if} or {@code #elseif}. + * <pre>{@code + * <if-token> -> #if ( <condition> ) + * <elseif-token> -> #elseif ( <condition> ) + * }</pre> + * + * @param directive either {@code "if"} or {@code "elseif"}. + */ + private Node parseIfOrElseIf(String directive) throws IOException { + expect('('); + ExpressionNode condition = parseExpression(); + expect(')'); + return directive.equals("if") ? new IfTokenNode(condition) : new ElseIfTokenNode(condition); + } + + /** + * Parses a {@code #foreach} token from the reader. <pre>{@code + * <foreach-token> -> #foreach ( $<id> in <expression> ) + * }</pre> + */ + private Node parseForEach() throws IOException { + expect('('); + expect('$'); + String var = parseId("For-each variable"); + skipSpace(); + boolean bad = false; + if (c != 'i') { + bad = true; + } else { + next(); + if (c != 'n') { + bad = true; + } + } + if (bad) { + throw parseException("Expected 'in' for #foreach"); + } + next(); + ExpressionNode collection = parseExpression(); + expect(')'); + return new ForEachTokenNode(var, collection); + } + + /** + * Parses a {@code #set} token from the reader. <pre>{@code + * <set-token> -> #set ( $<id> = <expression>) + * }</pre> + */ + private Node parseSet() throws IOException { + expect('('); + expect('$'); + String var = parseId("#set variable"); + expect('='); + ExpressionNode expression = parseExpression(); + expect(')'); + return new SetNode(var, expression); + } + + /** + * Parses a {@code #parse} token from the reader. <pre>{@code + * <parse-token> -> #parse ( <string-literal> ) + * }</pre> + * + * <p>The way this works is inconsistent with Velocity. In Velocity, the {@code #parse} directive + * is evaluated when it is encountered during template evaluation. That means that the argument + * can be a variable, and it also means that you can use {@code #if} to choose whether or not + * to do the {@code #parse}. Neither of those is true in EscapeVelocity. The contents of the + * {@code #parse} are integrated into the containing template pretty much as if they had been + * written inline. That also means that EscapeVelocity allows forward references to macros + * inside {@code #parse} directives, which Velocity does not. + */ + private Node parseParse() throws IOException { + expect('('); + skipSpace(); + if (c != '"') { + throw parseException("#parse only supported with string literal argument"); + } + String nestedResourceName = readStringLiteral(); + expect(')'); + try (Reader nestedReader = resourceOpener.openResource(nestedResourceName)) { + Parser nestedParser = new Parser(nestedReader, nestedResourceName, resourceOpener); + ImmutableList<Node> nestedTokens = nestedParser.parseTokens(); + return new NestedTokenNode(nestedResourceName, nestedTokens); + } + } + + /** + * Parses a {@code #macro} token from the reader. <pre>{@code + * <macro-token> -> #macro ( <id> <macro-parameter-list> ) + * <macro-parameter-list> -> <empty> | + * $<id> <macro-parameter-list> + * }</pre> + * + * <p>Macro parameters are not separated by commas, though method-reference parameters are. + */ + private Node parseMacroDefinition() throws IOException { + expect('('); + skipSpace(); + String name = parseId("Macro name"); + ImmutableList.Builder<String> parameterNames = ImmutableList.builder(); + while (true) { + skipSpace(); + if (c == ')') { + next(); + break; + } + if (c != '$') { + throw parseException("Macro parameters should look like $name"); + } + next(); + parameterNames.add(parseId("Macro parameter name")); + } + return new MacroDefinitionTokenNode(resourceName, lineNumber(), name, parameterNames.build()); + } + + /** + * Parses an identifier after {@code #} that is not one of the standard directives. The assumption + * is that it is a call of a macro that is defined in the template. Macro definitions are + * extracted from the template during the second parsing phase (and not during evaluation of the + * template as you might expect). This means that a macro can be called before it is defined. + * <pre>{@code + * <macro-call> -> # <id> ( <expression-list> ) + * <expression-list> -> <empty> | + * <expression> <optional-comma> <expression-list> + * <optional-comma> -> <empty> | , + * }</pre> + */ + private Node parsePossibleMacroCall(String directive) throws IOException { + skipSpace(); + if (c != '(') { + throw parseException("Unrecognized directive #" + directive); + } + next(); + ImmutableList.Builder<Node> parameterNodes = ImmutableList.builder(); + while (true) { + skipSpace(); + if (c == ')') { + next(); + break; + } + parameterNodes.add(parsePrimary()); + if (c == ',') { + // The documentation doesn't say so, but you can apparently have an optional comma in + // macro calls. + next(); + } + } + return new DirectiveNode.MacroCallNode( + resourceName, lineNumber(), directive, parameterNodes.build()); + } + + /** + * Parses and discards a comment, which is {@code ##} followed by any number of characters up to + * and including the next newline. + */ + private Node parseComment() throws IOException { + int lineNumber = lineNumber(); + while (c != '\n' && c != EOF) { + next(); + } + next(); + return new CommentTokenNode(resourceName, lineNumber); + } + + /** + * Parses plain text, which is text that contains neither {@code $} nor {@code #}. The given + * {@code firstChar} is the first character of the plain text, and {@link #c} is the second + * (if the plain text is more than one character). + */ + private Node parsePlainText(int firstChar) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.appendCodePoint(firstChar); + + literal: + while (true) { + switch (c) { + case EOF: + case '$': + case '#': + break literal; + default: + // Just some random character. + } + sb.appendCodePoint(c); + next(); + } + return new ConstantExpressionNode(resourceName, lineNumber(), sb.toString()); + } + + /** + * Parses a reference, which is everything that can start with a {@code $}. References can + * optionally be enclosed in braces, so {@code $x} and {@code ${x}} are the same. Braces are + * useful when text after the reference would otherwise be parsed as part of it. For example, + * {@code ${x}y} is a reference to the variable {@code $x}, followed by the plain text {@code y}. + * Of course {@code $xy} would be a reference to the variable {@code $xy}. + * <pre>{@code + * <reference> -> $<reference-no-brace> | + * ${<reference-no-brace>} + * }</pre> + * + * <p>On entry to this method, {@link #c} is the character immediately after the {@code $}. + */ + private ReferenceNode parseReference() throws IOException { + if (c == '{') { + next(); + ReferenceNode node = parseReferenceNoBrace(); + expect('}'); + return node; + } else { + return parseReferenceNoBrace(); + } + } + + /** + * Parses a reference, in the simple form without braces. + * <pre>{@code + * <reference-no-brace> -> <id><reference-suffix> + * }</pre> + */ + private ReferenceNode parseReferenceNoBrace() throws IOException { + String id = parseId("Reference"); + ReferenceNode lhs = new PlainReferenceNode(resourceName, lineNumber(), id); + return parseReferenceSuffix(lhs); + } + + /** + * Parses the modifiers that can appear at the tail of a reference. + * <pre>{@code + * <reference-suffix> -> <empty> | + * <reference-member> | + * <reference-index> + * }</pre> + * + * @param lhs the reference node representing the first part of the reference + * {@code $x} in {@code $x.foo} or {@code $x.foo()}, or later {@code $x.y} in {@code $x.y.z}. + */ + private ReferenceNode parseReferenceSuffix(ReferenceNode lhs) throws IOException { + switch (c) { + case '.': + return parseReferenceMember(lhs); + case '[': + return parseReferenceIndex(lhs); + default: + return lhs; + } + } + + /** + * Parses a reference member, which is either a property reference like {@code $x.y} or a method + * call like {@code $x.y($z)}. + * <pre>{@code + * <reference-member> -> .<id><reference-property-or-method><reference-suffix> + * <reference-property-or-method> -> <id> | + * <id> ( <method-parameter-list> ) + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x.foo} or {@code $x.foo()}. + */ + private ReferenceNode parseReferenceMember(ReferenceNode lhs) throws IOException { + assert c == '.'; + next(); + String id = parseId("Member"); + ReferenceNode reference; + if (c == '(') { + reference = parseReferenceMethodParams(lhs, id); + } else { + reference = new MemberReferenceNode(lhs, id); + } + return parseReferenceSuffix(reference); + } + + /** + * Parses the parameters to a method reference, like {@code $foo.bar($a, $b)}. + * <pre>{@code + * <method-parameter-list> -> <empty> | + * <non-empty-method-parameter-list> + * <non-empty-method-parameter-list> -> <expression> | + * <expression> , <non-empty-method-parameter-list> + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x.foo()}. + */ + private ReferenceNode parseReferenceMethodParams(ReferenceNode lhs, String id) + throws IOException { + assert c == '('; + nextNonSpace(); + ImmutableList.Builder<ExpressionNode> args = ImmutableList.builder(); + if (c != ')') { + args.add(parseExpression()); + while (c == ',') { + nextNonSpace(); + args.add(parseExpression()); + } + if (c != ')') { + throw parseException("Expected )"); + } + } + assert c == ')'; + next(); + return new MethodReferenceNode(lhs, id, args.build()); + } + + /** + * Parses an index suffix to a method, like {@code $x[$i]}. + * <pre>{@code + * <reference-index> -> [ <expression> ] + * }</pre> + * + * @param lhs the reference node representing what appears to the left of the dot, like the + * {@code $x} in {@code $x[$i]}. + */ + private ReferenceNode parseReferenceIndex(ReferenceNode lhs) throws IOException { + assert c == '['; + next(); + ExpressionNode index = parseExpression(); + if (c != ']') { + throw parseException("Expected ]"); + } + next(); + ReferenceNode reference = new IndexReferenceNode(lhs, index); + return parseReferenceSuffix(reference); + } + + enum Operator { + /** + * A dummy operator with low precedence. When parsing subexpressions, we always stop when we + * reach an operator of lower precedence than the "current precedence". For example, when + * parsing {@code 1 + 2 * 3 + 4}, we'll stop parsing the subexpression {@code * 3 + 4} when + * we reach the {@code +} because it has lower precedence than {@code *}. This dummy operator, + * then, behaves like {@code +} when the minimum precedence is {@code *}. We also return it + * if we're looking for an operator and don't find one. If this operator is {@code ⊙}, it's as + * if our expressions are bracketed with it, like {@code ⊙ 1 + 2 * 3 + 4 ⊙}. + */ + STOP("", 0), + + // If a one-character operator is a prefix of a two-character operator, like < and <=, then + // the one-character operator must come first. + OR("||", 1), + AND("&&", 2), + EQUAL("==", 3), NOT_EQUAL("!=", 3), + LESS("<", 4), LESS_OR_EQUAL("<=", 4), GREATER(">", 4), GREATER_OR_EQUAL(">=", 4), + PLUS("+", 5), MINUS("-", 5), + TIMES("*", 6), DIVIDE("/", 6), REMAINDER("%", 6); + + final String symbol; + final int precedence; + + Operator(String symbol, int precedence) { + this.symbol = symbol; + this.precedence = precedence; + } + + @Override + public String toString() { + return symbol; + } + } + + /** + * Maps a code point to the operators that begin with that code point. For example, maps + * {@code <} to {@code LESS} and {@code LESS_OR_EQUAL}. + */ + private static final Map<Integer, List<Operator>> CODE_POINT_TO_OPERATORS; + static { + Map<Integer, List<Operator>> map = new HashMap<>(); + for (Operator operator : Operator.values()) { + if (operator != Operator.STOP) { + Integer key = operator.symbol.codePointAt(0); + if (!map.containsKey(key)) { + map.put(key, new ArrayList<Operator>()); + } + map.get(key).add(operator); + } + } + CODE_POINT_TO_OPERATORS = Collections.unmodifiableMap(map); + } + + /** + * Parses an expression, which can occur within a directive like {@code #if} or {@code #set}, + * or within a reference like {@code $x[$a + $b]} or {@code $x.m($a + $b)}. + * <pre>{@code + * <expression> -> <and-expression> | + * <expression> || <and-expression> + * <and-expression> -> <relational-expression> | + * <and-expression> && <relational-expression> + * <equality-exression> -> <relational-expression> | + * <equality-expression> <equality-op> <relational-expression> + * <equality-op> -> == | != + * <relational-expression> -> <additive-expression> | + * <relational-expression> <relation> <additive-expression> + * <relation> -> < | <= | > | >= + * <additive-expression> -> <multiplicative-expression> | + * <additive-expression> <add-op> <multiplicative-expression> + * <add-op> -> + | - + * <multiplicative-expression> -> <unary-expression> | + * <multiplicative-expression> <mult-op> <unary-expression> + * <mult-op> -> * | / | % + * }</pre> + */ + private ExpressionNode parseExpression() throws IOException { + ExpressionNode lhs = parseUnaryExpression(); + return new OperatorParser().parse(lhs, 1); + } + + /** + * An operator-precedence parser for the binary operations we understand. It implements an + * <a href="http://en.wikipedia.org/wiki/Operator-precedence_parser">algorithm</a> from Wikipedia + * that uses recursion rather than having an explicit stack of operators and values. + */ + private class OperatorParser { + /** + * The operator we have just scanned, in the same way that {@link #c} is the character we have + * just read. If we were not able to scan an operator, this will be {@link Operator#STOP}. + */ + private Operator currentOperator; + + OperatorParser() throws IOException { + nextOperator(); + } + + /** + * Parse a subexpression whose left-hand side is {@code lhs} and where we only consider + * operators with precedence at least {@code minPrecedence}. + * + * @return the parsed subexpression + */ + ExpressionNode parse(ExpressionNode lhs, int minPrecedence) throws IOException { + while (currentOperator.precedence >= minPrecedence) { + Operator operator = currentOperator; + ExpressionNode rhs = parseUnaryExpression(); + nextOperator(); + while (currentOperator.precedence > operator.precedence) { + rhs = parse(rhs, currentOperator.precedence); + } + lhs = new BinaryExpressionNode(lhs, operator, rhs); + } + return lhs; + } + + /** + * Updates {@link #currentOperator} to be an operator read from the input, + * or {@link Operator#STOP} if there is none. + */ + private void nextOperator() throws IOException { + skipSpace(); + List<Operator> possibleOperators = CODE_POINT_TO_OPERATORS.get(c); + if (possibleOperators == null) { + currentOperator = Operator.STOP; + return; + } + int firstChar = c; + next(); + Operator operator = null; + for (Operator possibleOperator : possibleOperators) { + if (possibleOperator.symbol.length() == 1) { + assert operator == null; + operator = possibleOperator; + } else if (possibleOperator.symbol.charAt(1) == c) { + next(); + operator = possibleOperator; + } + } + if (operator == null) { + throw parseException("Expected " + possibleOperators.get(0) + ", not just " + firstChar); + } + currentOperator = operator; + } + } + + /** + * Parses an expression not containing any operators (except inside parentheses). + * <pre>{@code + * <unary-expression> -> <primary> | + * ( <expression> ) | + * ! <unary-expression> + * }</pre> + */ + private ExpressionNode parseUnaryExpression() throws IOException { + skipSpace(); + ExpressionNode node; + if (c == '(') { + nextNonSpace(); + node = parseExpression(); + expect(')'); + skipSpace(); + return node; + } else if (c == '!') { + next(); + node = new NotExpressionNode(parseUnaryExpression()); + skipSpace(); + return node; + } else { + return parsePrimary(); + } + } + + + /** + * Parses an expression containing only literals or references. + * <pre>{@code + * <primary> -> <reference> | + * <string-literal> | + * <integer-literal> | + * <boolean-literal> + * }</pre> + */ + private ExpressionNode parsePrimary() throws IOException { + ExpressionNode node; + if (c == '$') { + next(); + node = parseReference(); + } else if (c == '"') { + node = parseStringLiteral(); + } else if (c == '-') { + // Velocity does not have a negation operator. If we see '-' it must be the start of a + // negative integer literal. + next(); + node = parseIntLiteral("-"); + } else if (isAsciiDigit(c)) { + node = parseIntLiteral(""); + } else if (isAsciiLetter(c)) { + node = parseBooleanLiteral(); + } else { + throw parseException("Expected an expression"); + } + skipSpace(); + return node; + } + + private ExpressionNode parseStringLiteral() throws IOException { + return new ConstantExpressionNode(resourceName, lineNumber(), readStringLiteral()); + } + + private String readStringLiteral() throws IOException { + assert c == '"'; + StringBuilder sb = new StringBuilder(); + next(); + while (c != '"') { + if (c == '\n' || c == EOF) { + throw parseException("Unterminated string constant"); + } + if (c == '$' || c == '\\') { + // In real Velocity, you can have a $ reference expanded inside a "" string literal. + // There are also '' string literals where that is not so. We haven't needed that yet + // so it's not supported. + throw parseException( + "Escapes or references in string constants are not currently supported"); + } + sb.appendCodePoint(c); + next(); + } + next(); + return sb.toString(); + } + + private ExpressionNode parseIntLiteral(String prefix) throws IOException { + StringBuilder sb = new StringBuilder(prefix); + while (isAsciiDigit(c)) { + sb.appendCodePoint(c); + next(); + } + int value; + try { + value = Integer.parseInt(sb.toString()); + } catch (NumberFormatException e) { + throw parseException("Invalid integer: " + sb); + } + return new ConstantExpressionNode(resourceName, lineNumber(), value); + } + + /** + * Parses a boolean literal, either {@code true} or {@code false}. + * <boolean-literal> -> true | + * false + */ + private ExpressionNode parseBooleanLiteral() throws IOException { + String s = parseId("Identifier without $"); + boolean value; + if (s.equals("true")) { + value = true; + } else if (s.equals("false")) { + value = false; + } else { + throw parseException("Identifier in expression must be preceded by $ or be true or false"); + } + return new ConstantExpressionNode(resourceName, lineNumber(), value); + } + + private static final ImmutableAsciiSet ASCII_LETTER = + ImmutableAsciiSet.ofRange('A', 'Z') + .union(ImmutableAsciiSet.ofRange('a', 'z')); + + private static final ImmutableAsciiSet ASCII_DIGIT = + ImmutableAsciiSet.ofRange('0', '9'); + + private static final ImmutableAsciiSet ID_CHAR = + ASCII_LETTER + .union(ASCII_DIGIT) + .union(ImmutableAsciiSet.of('-')) + .union(ImmutableAsciiSet.of('_')); + + private static boolean isAsciiLetter(int c) { + return ASCII_LETTER.contains(c); + } + + private static boolean isAsciiDigit(int c) { + return ASCII_DIGIT.contains(c); + } + + private static boolean isIdChar(int c) { + return ID_CHAR.contains(c); + } + + /** + * Parse an identifier as specified by the + * <a href="http://velocity.apache.org/engine/devel/vtl-reference-guide.html#Variables">VTL + * </a>. Identifiers are ASCII: starts with a letter, then letters, digits, {@code -} and + * {@code _}. + */ + private String parseId(String what) throws IOException { + if (!isAsciiLetter(c)) { + throw parseException(what + " should start with an ASCII letter"); + } + StringBuilder id = new StringBuilder(); + while (isIdChar(c)) { + id.appendCodePoint(c); + next(); + } + return id.toString(); + } + + /** + * Returns an exception to be thrown describing a parse error with the given message, and + * including information about where it occurred. + */ + private ParseException parseException(String message) throws IOException { + StringBuilder context = new StringBuilder(); + if (c == EOF) { + context.append("EOF"); + } else { + int count = 0; + while (c != EOF && count < 20) { + context.appendCodePoint(c); + next(); + count++; + } + if (c != EOF) { + context.append("..."); + } + } + return new ParseException(message, resourceName, lineNumber(), context.toString()); + } +} diff --git a/src/main/java/com/google/escapevelocity/README.md b/src/main/java/com/google/escapevelocity/README.md new file mode 100644 index 0000000..0e9ff1e --- /dev/null +++ b/src/main/java/com/google/escapevelocity/README.md @@ -0,0 +1,378 @@ +# EscapeVelocity summary + +EscapeVelocity is a templating engine that can be used from Java. It is a reimplementation of a subset of +functionality from [Apache Velocity](http://velocity.apache.org/). + +This is not a supported Google product. + +For a fuller explanation of Velocity's functioning, see its +[User Guide](http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html) + +If EscapeVelocity successfully produces a result from a template evaluation, that result should be +the exact same string that Velocity produces. If not, that is a bug. + +EscapeVelocity has no facilities for HTML escaping and it is not appropriate for producing +HTML output that might include portions of untrusted input. + +## Motivation + +Velocity has a convenient templating language. It is easy to read, and it has widespread support +from tools such as editors and coding websites. However, *using* Velocity can prove difficult. +Its use to generate Java code in the [AutoValue][AutoValue] annotation processor required many +[workarounds][VelocityHacks]. The way it dynamically loads classes as part of its standard operation +makes it hard to [shade](https://maven.apache.org/plugins/maven-shade-plugin/) it, which in the case +of AutoValue led to interference if Velocity was used elsewhere in a project. + +EscapeVelocity has a simple API that does not involve any class-loading or other sources of +problems. It and its dependencies can be shaded with no difficulty. + +## Loading a template + +The entry point for EscapeVelocity is the `Template` class. To obtain an instance, use +`Template.from(Reader)`. If a template is stored in a file, that file conventionally has the +suffix `.vm` (for Velocity Macros). But since the argument is a `Reader`, you can also load +a template directly from a Java string, using `StringReader`. + +Here's how you might make a `Template` instance from a template file that is packaged as a resource +in the same package as the calling class: + +```java +InputStream in = getClass().getResourceAsStream("foo.vm"); +if (in == null) { + throw new IllegalArgumentException("Could not find resource foo.vm"); +} +Reader reader = new BufferedReader(new InputStreamReader(in)); +Template template = Template.parseFrom(reader); +``` + +## Expanding a template + +Once you have a `Template` object, you can use it to produce a string where the variables in the +template are given the values you provide. You can do this any number of times, specifying the +same or different values each time. + +Suppose you have this template: + +``` +The $language word for $original is $translated. +``` + +You might write this code: + +```java +Map<String, String> vars = new HashMap<>(); +vars.put("language", "French"); +vars.put("original", "toe"); +vars.put("translated", "orteil"); +String result = template.evaluate(vars); +``` + +The `result` string would then be: `The French word for toe is orteil.` + +## Comments + +The characters `##` introduce a comment. Characters from `##` up to and including the following +newline are omitted from the template. This template has comments: + +``` +Line 1 ## with a comment +Line 2 +``` + +It is the same as this template: +``` +Line 1 Line 2 +``` + +## References + +EscapeVelocity supports most of the reference types described in the +[Velocity User Guide](http://velocity.apache.org/engine/releases/velocity-1.7/user-guide.html#References) + +### Variables + +A variable has an ASCII name that starts with a letter (a-z or A-Z) and where any other characters +are also letters or digits or hyphens (-) or underscores (_). A variable reference can be written +as `$foo` or as `${foo}`. The value of a variable can be of any Java type. If the value `v` of +variable `foo` is not a String then the result of `$foo` in a template will be `String.valueOf(v)`. +Variables must be defined before they are referenced; otherwise an `EvaluationException` will be +thrown. + +Variable names are case-sensitive: `$foo` is not the same variable as `$Foo` or `$FOO`. + +Initially the values of variables come from the Map that is passed to `Template.evaluate`. Those +values can be changed, and new ones defined, using the `#set` directive in the template: + +``` +#set ($foo = "bar") +``` + +Setting a variable affects later references to it in the template, but has no effect on the +`Map` that was passed in or on later template evaluations. + +### Properties + +If a reference looks like `$purchase.Total` then the value of the `$purchase` variable must be a +Java object that has a public method `getTotal()` or `gettotal()`, or a method called `isTotal()` or +`istotal()` that returns `boolean`. The result of `$purchase.Total` is then the result of calling +that method on the `$purchase` object. + +If you want to have a period (`.`) after a variable reference *without* it being a property +reference, you can use braces like this: `${purchase}.Total`. If, after a property reference, you +have a further period, you can put braces around the reference like this: +`${purchase.Total}.nonProperty`. + +### Methods + +If a reference looks like `$purchase.addItem("scones", 23)` then the value of the `$purchase` +variable must be a Java object that has a public method `addItem` with two parameters that match +the given values. Unlike Velocity, EscapeVelocity requires that there be exactly one such method. +It is OK if there are other `addItem` methods provided they are not compatible with the +arguments provided. + +Properties are in fact a special case of methods: instead of writing `$purchase.Total` you could +write `$purchase.getTotal()`. Braces can be used to make the method invocation explicit +(`${purchase.getTotal()}`) or to prevent method invocation (`${purchase}.getTotal()`). + +### Indexing + +If a reference looks like `$indexme[$i]` then the value of the `$indexme` variable must be a Java +object that has a public `get` method that takes one argument that is compatible with the index. +For example, `$indexme` might be a `List` and `$i` might be an integer. Then the reference would +be the result of `List.get(int)` for that list and that integer. Or, `$indexme` might be a `Map`, +and the reference would be the result of `Map.get(Object)` for the object `$i`. In general, +`$indexme[$i]` is equivalent to `$indexme.get($i)`. + +Unlike Velocity, EscapeVelocity does not allow `$indexme` to be a Java array. + +### Undefined references + +If a variable has not been given a value, either by being in the initial Map argument or by being +set in the template, then referencing it will provoke an `EvaluationException`. There is +a special case for `#if`: if you write `#if ($var)` then it is allowed for `$var` not to be defined, +and it is treated as false. + +### Setting properties and indexes: not supported + +Unlke Velocity, EscapeVelocity does not allow `#set` assignments with properties or indexes: + +``` +#set ($data.User = "jon") ## Allowed in Velocity but not in EscapeVelocity +#set ($map["apple"] = "orange") ## Allowed in Velocity but not in EscapeVelocity +``` + +## Expressions + +In certain contexts, such as the `#set` directive we have just seen or certain other directives, +EscapeVelocity can evaluate expressions. An expression can be any of these: + +* A reference, of the kind we have just seen. The value is the value of the reference. +* A string literal enclosed in double quotes, like `"this"`. A string literal must appear on + one line. EscapeVelocity does not support the characters `$` or `\\` in a string literal. +* An integer literal such as `23` or `-100`. EscapeVelocity does not support floating-point + literals. +* A Boolean literal, `true` or `false`. +* Simpler expressions joined together with operators that have the same meaning as in Java: + `!`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `+`, `-`, `*`, `/`, `%`. The operators have the + same precedence as in Java. +* A simpler expression in parentheses, for example `(2 + 3)`. + +Velocity supports string literals with single quotes, like `'this`' and also references within +strings, like `"a $reference in a string"`, but EscapeVelocity does not. + +## Directives + +A directive is introduced by a `#` character followed by a word. We have already seen the `#set` +directive, which sets the value of a variable. The other directives are listed below. + +Directives can be spelled with or without braces, so `#set` or `#{set}`. + +### `#if`/`#elseif`/`#else` + +The `#if` directive selects parts of the template according as a condition is true or false. +The simplest case looks like this: + +``` +#if ($condition) yes #end +``` + +This evaluates to the string ` yes ` if the variable `$condition` is defined and has a true value, +and to the empty string otherwise. It is allowed for `$condition` not to be defined in this case, +and then it is treated as false. + +The expression in `#if` (here `$condition`) is considered true if its value is not null and not +equal to the Boolean value `false`. + +An `#if` directive can also have an `#else` part, for example: + +``` +#if ($condition) yes #else no #end +``` + +This evaluates to the string ` yes ` if the condition is true or the string ` no ` if it is not. + +An `#if` directive can have any number of `#elseif` parts. For example: + +``` +#if ($i == 0) zero #elseif ($i == 1) one #elseif ($i == 2) two #else many #end +``` + +### `#foreach` + +The `#foreach` directive repeats a part of the template once for each value in a list. + +``` +#foreach ($product in $allProducts) + ${product}! +#end +``` + +This will produce one line for each value in the `$allProducts` variable. The value of +`$allProducts` can be a Java `Iterable`, such as a `List` or `Set`; or it can be an object array; +or it can be a Java `Map`. When it is a `Map` the `#foreach` directive loops over every *value* +in the `Map`. + +If `$allProducts` is a `List` containing the strings `oranges` and `lemons` then the result of the +`#foreach` would be this: + +``` + + oranges! + + + lemons! + +``` + +When the `#foreach` completes, the loop variable (`$product` in the example) goes back to whatever +value it had before, or to being undefined if it was undefined before. + +Within the `#foreach`, a special variable `$foreach` is defined, such that you can write +`$foreach.hasNext`, which will be true if there are more values after this one or false if this +is the last value. For example: + +``` +#foreach ($product in $allProducts)${product}#if ($foreach.hasNext), #end#end +``` + +This would produce the output `oranges, lemons` for the list above. (The example is scrunched up +to avoid introducing extraneous spaces, as described in the [section](#spaces) on spaces +below.) + +Velocity gives the `$foreach` variable other properties (`index` and `count`) but EscapeVelocity +does not. + +### Macros + +A macro is a part of the template that can be reused in more than one place, potentially with +different parameters each time. In the simplest case, a macro has no arguments: + +``` +#macro (hello) bonjour #end +``` + +Then the macro can be referenced by writing `#hello()` and the result will be the string ` bonjour ` +inserted at that point. + +Macros can also have parameters: + +``` +#macro (greet $hello $world) $hello, $world! #end +``` + +Then `#greet("bonjour", "monde")` would produce ` bonjour, monde! `. The comma is optional, so +you could also write `#greet("bonjour" "monde")`. + +When a macro completes, the parameters (`$hello` and `$world` in the example) go back to whatever +values they had before, or to being undefined if they were undefined before. + +All macro definitions take effect before the template is evaluated, so you can use a macro at a +point in the template that is before the point where it is defined. This also means that you can't +define a macro conditionally: + +``` +## This doesn't work! +#if ($language == "French") +#macro (hello) bonjour #end +#else +#macro (hello) hello #end +#end +``` + +There is no particular reason to define the same macro more than once, but if you do it is the +first definition that is retained. In the `#if` example just above, the `bonjour` version will +always be used. + +Macros can make templates hard to understand. You may prefer to put the logic in a Java method +rather than a macro, and call the method from the template using `$methods.doSomething("foo")` +or whatever. + +## Block quoting + +If you have text that should be treated verbatim, you can enclose it in `#[[...]]#`. The text +represented by `...` will be copied into the output. `#` and `$` characters will have no +effect in that text. + +``` +#[[ This is not a #directive, and this is not a $variable. ]]# +``` + +## Including other templates + +If you want to include a template from another file, you can use the `#parse` directive. +This can be useful if you have macros that are shared between templates, for example. + +``` +#set ($foo = "bar") +#parse("macros.vm") +#mymacro($foo) ## #mymacro defined in macros.vm +``` + +For this to work, you will need to tell EscapeVelocity how to find "resources" such as +`macro.vm` in the example. You might use something like this: + +``` +ResourceOpener resourceOpener = resourceName -> { + InputStream inputStream = getClass().getResource(resourceName); + if (inputStream == null) { + throw new IOException("Unknown resource: " + resourceName); + } + return new BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8)); +}; +Template template = Template.parseFrom("foo.vm", resourceOpener); +``` + +In this case, the `resourceOpener` is used to find the main template `foo.vm`, as well as any +templates it may reference in `#parse` directives. + +## <a name="spaces"></a> Spaces + +For the most part, spaces and newlines in the template are preserved exactly in the output. +To avoid unwanted newlines, you may end up using `##` comments. In the `#foreach` example above +we had this: + +``` +#foreach ($product in $allProducts)${product}#if ($foreach.hasNext), #end#end +``` + +That was to avoid introducing unwanted spaces and newlines. A more readable way to achieve the same +result is this: + +``` +#foreach ($product in $allProducts)## +${product}## +#if ($foreach.hasNext), #end## +#end +``` + +Spaces are ignored between the `#` of a directive and the `)` that closes it, so there is no trace +in the output of the spaces in `#foreach ($product in $allProducts)` or `#if ($foreach.hasNext)`. +Spaces are also ignored inside references, such as `$indexme[ $i ]` or `$callme( $i , $j )`. + +If you are concerned about the detailed formatting of the text from the template, you may want to +post-process it. For example, if it is Java code, you could use a formatter such as +[google-java-format](https://github.com/google/google-java-format). Then you shouldn't have to +worry about extraneous spaces. + +[VelocityHacks]: https://github.com/google/auto/blob/ca2384d5ad15a0c761b940384083cf5c50c6e839/value/src/main/java/com/google/auto/value/processor/TemplateVars.java#L54 +[AutoValue]: https://github.com/google/auto/tree/master/value diff --git a/src/main/java/com/google/escapevelocity/ReferenceNode.java b/src/main/java/com/google/escapevelocity/ReferenceNode.java new file mode 100644 index 0000000..865d02a --- /dev/null +++ b/src/main/java/com/google/escapevelocity/ReferenceNode.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A node in the parse tree that is a reference. A reference is anything beginning with {@code $}, + * such as {@code $x} or {@code $x[$i].foo($j)}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class ReferenceNode extends ExpressionNode { + ReferenceNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * A node in the parse tree that is a plain reference such as {@code $x}. This node may appear + * inside a more complex reference like {@code $x.foo}. + */ + static class PlainReferenceNode extends ReferenceNode { + final String id; + + PlainReferenceNode(String resourceName, int lineNumber, String id) { + super(resourceName, lineNumber); + this.id = id; + } + + @Override Object evaluate(EvaluationContext context) { + if (context.varIsDefined(id)) { + return context.getVar(id); + } else { + throw evaluationException("Undefined reference $" + id); + } + } + + @Override + boolean isDefinedAndTrue(EvaluationContext context) { + if (context.varIsDefined(id)) { + return isTrue(context); + } else { + return false; + } + } + } + + /** + * A node in the parse tree that is a reference to a property of another reference, like + * {@code $x.foo} or {@code $x[$i].foo}. + */ + static class MemberReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final String id; + + MemberReferenceNode(ReferenceNode lhs, String id) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.id = id; + } + + private static final String[] PREFIXES = {"get", "is"}; + private static final boolean[] CHANGE_CASE = {false, true}; + + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot get member " + id + " of null value"); + } + // Velocity specifies that, given a reference .foo, it will first look for getfoo() and then + // for getFoo(), and likewise given .Foo it will look for getFoo() and then getfoo(). + for (String prefix : PREFIXES) { + for (boolean changeCase : CHANGE_CASE) { + String baseId = changeCase ? changeInitialCase(id) : id; + String methodName = prefix + baseId; + Method method; + try { + method = lhsValue.getClass().getMethod(methodName); + if (!prefix.equals("is") || method.getReturnType().equals(boolean.class)) { + // Don't consider methods that happen to be called isFoo() but don't return boolean. + return invokeMethod(method, lhsValue, ImmutableList.of()); + } + } catch (NoSuchMethodException e) { + // Continue with next possibility + } + } + } + throw evaluationException( + "Member " + id + " does not correspond to a public getter of " + lhsValue + + ", a " + lhsValue.getClass().getName()); + } + + private static String changeInitialCase(String id) { + int initial = id.codePointAt(0); + String rest = id.substring(Character.charCount(initial)); + if (Character.isUpperCase(initial)) { + initial = Character.toLowerCase(initial); + } else if (Character.isLowerCase(initial)) { + initial = Character.toUpperCase(initial); + } + return new StringBuilder().appendCodePoint(initial).append(rest).toString(); + } + } + + /** + * A node in the parse tree that is an indexing of a reference, like {@code $x[0]} or + * {@code $x.foo[$i]}. Indexing is array indexing or calling the {@code get} method of a list + * or a map. + */ + static class IndexReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final ExpressionNode index; + + IndexReferenceNode(ReferenceNode lhs, ExpressionNode index) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.index = index; + } + + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot index null value"); + } + if (lhsValue instanceof List<?>) { + Object indexValue = index.evaluate(context); + if (!(indexValue instanceof Integer)) { + throw evaluationException("List index is not an integer: " + indexValue); + } + List<?> lhsList = (List<?>) lhsValue; + int i = (Integer) indexValue; + if (i < 0 || i >= lhsList.size()) { + throw evaluationException( + "List index " + i + " is not valid for list of size " + lhsList.size()); + } + return lhsList.get(i); + } else if (lhsValue instanceof Map<?, ?>) { + Object indexValue = index.evaluate(context); + Map<?, ?> lhsMap = (Map<?, ?>) lhsValue; + return lhsMap.get(indexValue); + } else { + // In general, $x[$y] is equivalent to $x.get($y). We've covered the most common cases + // above, but for other cases like Multimap we resort to evaluating the equivalent form. + MethodReferenceNode node = new MethodReferenceNode(lhs, "get", ImmutableList.of(index)); + return node.evaluate(context); + } + } + } + + /** + * A node in the parse tree representing a method reference, like {@code $list.size()}. + */ + static class MethodReferenceNode extends ReferenceNode { + final ReferenceNode lhs; + final String id; + final List<ExpressionNode> args; + + MethodReferenceNode(ReferenceNode lhs, String id, List<ExpressionNode> args) { + super(lhs.resourceName, lhs.lineNumber); + this.lhs = lhs; + this.id = id; + this.args = args; + } + + /** + * {@inheritDoc} + * + * <p>Evaluating a method expression such as {@code $x.foo($y)} involves looking at the actual + * types of {@code $x} and {@code $y}. The type of {@code $x} must have a public method + * {@code foo} with a parameter type that is compatible with {@code $y}. + * + * <p>Currently we don't allow there to be more than one matching method. That is a difference + * from Velocity, which blithely allows you to invoke {@link List#remove(int)} even though it + * can't really know that you didn't mean to invoke {@link List#remove(Object)} with an Object + * that just happens to be an Integer. + * + * <p>The method to be invoked must be visible in a public class or interface that is either the + * class of {@code $x} itself or one of its supertypes. Allowing supertypes is important because + * you may want to invoke a public method like {@link List#size()} on a list whose class is not + * public, such as the list returned by {@link java.util.Collections#singletonList}. + */ + @Override Object evaluate(EvaluationContext context) { + Object lhsValue = lhs.evaluate(context); + if (lhsValue == null) { + throw evaluationException("Cannot invoke method " + id + " on null value"); + } + List<Object> argValues = new ArrayList<>(); + for (ExpressionNode arg : args) { + argValues.add(arg.evaluate(context)); + } + List<Method> methodsWithName = new ArrayList<>(); + for (Method method : lhsValue.getClass().getMethods()) { + if (method.getName().equals(id) && !method.isSynthetic()) { + methodsWithName.add(method); + } + } + if (methodsWithName.isEmpty()) { + throw evaluationException("No method " + id + " in " + lhsValue.getClass().getName()); + } + List<Method> compatibleMethods = new ArrayList<>(); + for (Method method : methodsWithName) { + // TODO(emcmanus): support varargs, if it's useful + if (compatibleArgs(method.getParameterTypes(), argValues)) { + compatibleMethods.add(method); + } + } + switch (compatibleMethods.size()) { + case 0: + throw evaluationException( + "Parameters for method " + id + " have wrong types: " + argValues); + case 1: + return invokeMethod(compatibleMethods.get(0), lhsValue, argValues); + default: + StringBuilder error = new StringBuilder("Ambiguous method invocation, could be one of:"); + for (Method method : compatibleMethods) { + error.append("\n ").append(method); + } + throw evaluationException(error.toString()); + } + } + + /** + * Determines if the given argument list is compatible with the given parameter types. This + * includes an {@code Integer} argument being compatible with a parameter of type {@code int} or + * {@code long}, for example. + */ + static boolean compatibleArgs(Class<?>[] paramTypes, List<Object> argValues) { + if (paramTypes.length != argValues.size()) { + return false; + } + for (int i = 0; i < paramTypes.length; i++) { + Class<?> paramType = paramTypes[i]; + Object argValue = argValues.get(i); + if (paramType.isPrimitive()) { + return primitiveIsCompatible(paramType, argValue); + } else if (!paramType.isInstance(argValue)) { + return false; + } + } + return true; + } + + private static final Map<Class<?>, Class<?>> BOXED_TO_UNBOXED; + static { + Map<Class<?>, Class<?>> map = new HashMap<>(); + map.put(Byte.class, byte.class); + map.put(Short.class, short.class); + map.put(Integer.class, int.class); + map.put(Long.class, long.class); + map.put(Float.class, float.class); + map.put(Double.class, double.class); + map.put(Character.class, char.class); + map.put(Boolean.class, boolean.class); + BOXED_TO_UNBOXED = Collections.unmodifiableMap(map); + } + + private static boolean primitiveIsCompatible(Class<?> primitive, Object value) { + if (value == null) { + return false; + } + Class<?> unboxed = BOXED_TO_UNBOXED.get(value.getClass()); + if (unboxed == null) { + return false; + } + return primitiveTypeIsAssignmentCompatible(primitive, unboxed); + } + + private static final ImmutableList<Class<?>> NUMERICAL_PRIMITIVES = ImmutableList.<Class<?>>of( + byte.class, short.class, int.class, long.class, float.class, double.class); + private static final int INDEX_OF_INT = NUMERICAL_PRIMITIVES.indexOf(int.class); + + /** + * Returns true if {@code from} can be assigned to {@code to} according to + * <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2">Widening + * Primitive Conversion</a>. + */ + static boolean primitiveTypeIsAssignmentCompatible(Class<?> to, Class<?> from) { + // To restate the JLS rules, f can be assigned to t if: + // - they are the same; or + // - f is char and t is a numeric type at least as wide as int; or + // - f comes before t in the order byte, short, int, long, float, double. + if (to == from) { + return true; + } + int toI = NUMERICAL_PRIMITIVES.indexOf(to); + if (toI < 0) { + return false; + } + if (from == char.class) { + return toI >= INDEX_OF_INT; + } + int fromI = NUMERICAL_PRIMITIVES.indexOf(from); + if (fromI < 0) { + return false; + } + return toI >= fromI; + } + } + + /** + * Invoke the given method on the given target with the given arguments. The method is expected + * to be public, but the class it is in might not be. In that case we will search up the + * hierarchy for an ancestor that is public and has the same method, and use that to invoke the + * method. Otherwise we would get an {@link IllegalAccessException}. More than one ancestor might + * define the method, but it doesn't matter which one we invoke since ultimately the code that + * will run will be the same. + */ + Object invokeMethod(Method method, Object target, List<Object> argValues) { + if (!classIsPublic(target.getClass())) { + method = visibleMethod(method, target.getClass()); + if (method == null) { + throw evaluationException( + "Method is not visible in class " + target.getClass().getName() + ": " + method); + } + } + try { + return method.invoke(target, argValues.toArray()); + } catch (InvocationTargetException e) { + throw evaluationException(e.getCause()); + } catch (Exception e) { + throw evaluationException(e); + } + } + + private static String packageNameOf(Class<?> c) { + String name = c.getName(); + int lastDot = name.lastIndexOf('.'); + if (lastDot > 0) { + return name.substring(0, lastDot); + } else { + return ""; + } + } + + private static final String THIS_PACKAGE = packageNameOf(Node.class) + "."; + + /** + * Returns a Method with the same name and parameter types as the given one, but that is in a + * public class or interface. This might be the given method, or it might be a method in a + * superclass or superinterface. + * + * @return a public method in a public class or interface, or null if none was found. + */ + static Method visibleMethod(Method method, Class<?> in) { + if (in == null) { + return null; + } + Method methodInClass; + try { + methodInClass = in.getMethod(method.getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + return null; + } + if (classIsPublic(in) || in.getName().startsWith(THIS_PACKAGE)) { + // The second disjunct is a hack to allow us to use the methods of $foreach without having + // to make the ForEachVar class public. We can invoke those methods from here since they + // are in the same package. + return methodInClass; + } + Method methodSuper = visibleMethod(method, in.getSuperclass()); + if (methodSuper != null) { + return methodSuper; + } + for (Class<?> intf : in.getInterfaces()) { + Method methodIntf = visibleMethod(method, intf); + if (methodIntf != null) { + return methodIntf; + } + } + return null; + } + + /** + * Returns whether the given class is public as seen from this class. Prior to Java 9, a class + * was either public or not public. But with the introduction of modules in Java 9, a class can + * be marked public and yet not be visible, if it is not exported from the module it appears in. + * So, on Java 9, we perform an additional check on class {@code c}, which is effectively + * {@code c.getModule().isExported(c.getPackageName())}. We use reflection so that the code can + * compile on earlier Java versions. + */ + private static boolean classIsPublic(Class<?> c) { + if (!Modifier.isPublic(c.getModifiers())) { + return false; + } + if (CLASS_GET_MODULE_METHOD != null) { + return classIsExported(c); + } + return true; + } + + private static boolean classIsExported(Class<?> c) { + try { + String pkg = packageNameOf(c); + Object module = CLASS_GET_MODULE_METHOD.invoke(c); + return (Boolean) MODULE_IS_EXPORTED_METHOD.invoke(module, pkg); + } catch (Exception e) { + return false; + } + } + + private static final Method CLASS_GET_MODULE_METHOD; + private static final Method MODULE_IS_EXPORTED_METHOD; + + static { + Method classGetModuleMethod; + Method moduleIsExportedMethod; + try { + classGetModuleMethod = Class.class.getMethod("getModule"); + Class<?> moduleClass = classGetModuleMethod.getReturnType(); + moduleIsExportedMethod = moduleClass.getMethod("isExported", String.class); + } catch (Exception e) { + classGetModuleMethod = null; + moduleIsExportedMethod = null; + } + CLASS_GET_MODULE_METHOD = classGetModuleMethod; + MODULE_IS_EXPORTED_METHOD = moduleIsExportedMethod; + } +} diff --git a/src/main/java/com/google/escapevelocity/Reparser.java b/src/main/java/com/google/escapevelocity/Reparser.java new file mode 100644 index 0000000..6235bc4 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Reparser.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.escapevelocity.Node.emptyNode; + +import com.google.escapevelocity.DirectiveNode.ForEachNode; +import com.google.escapevelocity.DirectiveNode.IfNode; +import com.google.escapevelocity.DirectiveNode.MacroCallNode; +import com.google.escapevelocity.DirectiveNode.SetNode; +import com.google.escapevelocity.TokenNode.CommentTokenNode; +import com.google.escapevelocity.TokenNode.ElseIfTokenNode; +import com.google.escapevelocity.TokenNode.ElseTokenNode; +import com.google.escapevelocity.TokenNode.EndTokenNode; +import com.google.escapevelocity.TokenNode.EofNode; +import com.google.escapevelocity.TokenNode.ForEachTokenNode; +import com.google.escapevelocity.TokenNode.IfOrElseIfTokenNode; +import com.google.escapevelocity.TokenNode.IfTokenNode; +import com.google.escapevelocity.TokenNode.MacroDefinitionTokenNode; +import com.google.escapevelocity.TokenNode.NestedTokenNode; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * The second phase of parsing. See {@link Parser#parse()} for a description of the phases and why + * we need them. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +class Reparser { + private static final ImmutableSet<Class<? extends TokenNode>> END_SET = + ImmutableSet.<Class<? extends TokenNode>>of(EndTokenNode.class); + private static final ImmutableSet<Class<? extends TokenNode>> EOF_SET = + ImmutableSet.<Class<? extends TokenNode>>of(EofNode.class); + private static final ImmutableSet<Class<? extends TokenNode>> ELSE_ELSE_IF_END_SET = + ImmutableSet.<Class<? extends TokenNode>>of( + ElseTokenNode.class, ElseIfTokenNode.class, EndTokenNode.class); + + /** + * The nodes that make up the input sequence. Nodes are removed one by one from this list as + * parsing proceeds. At any time, {@link #currentNode} is the node being examined. + */ + private final ImmutableList<Node> nodes; + + /** + * The index of the node we are currently looking at while parsing. + */ + private int nodeIndex; + + /** + * Macros are removed from the input as they are found. They do not appear in the output parse + * tree. Macro definitions are not executed in place but are all applied before template rendering + * starts. This means that a macro can be referenced before it is defined. + */ + private final Map<String, Macro> macros; + + Reparser(ImmutableList<Node> nodes) { + this(nodes, new TreeMap<String, Macro>()); + } + + private Reparser(ImmutableList<Node> nodes, Map<String, Macro> macros) { + this.nodes = removeSpaceBeforeSet(nodes); + this.nodeIndex = 0; + this.macros = macros; + } + + Template reparse() { + Node root = reparseNodes(); + linkMacroCalls(); + return new Template(root); + } + + private Node reparseNodes() { + return parseTo(EOF_SET, new EofNode((String) null, 1)); + } + + /** + * Returns a copy of the given list where spaces have been moved where appropriate after {@code + * #set}. This hack is needed to match what appears to be special treatment in Apache Velocity of + * spaces before {@code #set} directives. If you have <i>thing</i> <i>whitespace</i> {@code #set}, + * then the whitespace is deleted if the <i>thing</i> is a comment ({@code ##...\n}); a reference + * ({@code $x} or {@code $x.foo} etc); a macro definition; or another {@code #set}. + */ + private static ImmutableList<Node> removeSpaceBeforeSet(ImmutableList<Node> nodes) { + assert nodes.get(nodes.size() - 1) instanceof EofNode : nodes.get(nodes.size() - 1); + // Since the last node is EofNode, the i + 1 and i + 2 accesses below are safe. + ImmutableList.Builder<Node> newNodes = ImmutableList.builder(); + for (int i = 0; i < nodes.size(); i++) { + Node nodeI = nodes.get(i); + newNodes.add(nodeI); + if (shouldDeleteSpaceBetweenThisAndSet(nodeI) + && isWhitespaceLiteral(nodes.get(i + 1)) + && nodes.get(i + 2) instanceof SetNode) { + // Skip the space. + i++; + } + } + return newNodes.build(); + } + + private static boolean shouldDeleteSpaceBetweenThisAndSet(Node node) { + return node instanceof CommentTokenNode + || node instanceof ReferenceNode + || node instanceof SetNode + || node instanceof MacroDefinitionTokenNode; + } + + private static boolean isWhitespaceLiteral(Node node) { + if (node instanceof ConstantExpressionNode) { + Object constant = node.evaluate(null); + if (constant instanceof String) { + String s = (String) constant; + int i = 0; + while (i < s.length()) { + int c = s.codePointAt(i); + if (!Character.isWhitespace(c)) { + return false; + } + i += Character.charCount(c); + } + return true; + } + } + return false; + } + + /** + * Parse subtrees until one of the token types in {@code stopSet} is encountered. + * If this is the top level, {@code stopSet} will include {@link EofNode} so parsing will stop + * when it reaches the end of the input. Otherwise, if an {@code EofNode} is encountered it is an + * error because we have something like {@code #if} without {@code #end}. + * + * @param stopSet the kinds of tokens that will stop the parse. For example, if we are parsing + * after an {@code #if}, we will stop at any of {@code #else}, {@code #elseif}, + * or {@code #end}. + * @param forWhat the token that triggered this call, for example the {@code #if} whose + * {@code #end} etc we are looking for. + * + * @return a Node that is the concatenation of the parsed subtrees + */ + private Node parseTo(Set<Class<? extends TokenNode>> stopSet, TokenNode forWhat) { + ImmutableList.Builder<Node> nodeList = ImmutableList.builder(); + while (true) { + Node currentNode = currentNode(); + if (stopSet.contains(currentNode.getClass())) { + break; + } + if (currentNode instanceof EofNode) { + throw new ParseException( + "Reached end of file while parsing " + forWhat.name(), + forWhat.resourceName, + forWhat.lineNumber); + } + Node parsed; + if (currentNode instanceof TokenNode) { + parsed = parseTokenNode(); + } else { + parsed = currentNode; + nextNode(); + } + nodeList.add(parsed); + } + return Node.cons(forWhat.resourceName, forWhat.lineNumber, nodeList.build()); + } + + private Node currentNode() { + return nodes.get(nodeIndex); + } + + private Node nextNode() { + Node currentNode = currentNode(); + if (currentNode instanceof EofNode) { + return currentNode; + } else { + nodeIndex++; + return currentNode(); + } + } + + private Node parseTokenNode() { + TokenNode tokenNode = (TokenNode) currentNode(); + nextNode(); + if (tokenNode instanceof CommentTokenNode) { + return emptyNode(tokenNode.resourceName, tokenNode.lineNumber); + } else if (tokenNode instanceof IfTokenNode) { + return parseIfOrElseIf((IfTokenNode) tokenNode); + } else if (tokenNode instanceof ForEachTokenNode) { + return parseForEach((ForEachTokenNode) tokenNode); + } else if (tokenNode instanceof NestedTokenNode) { + return parseNested((NestedTokenNode) tokenNode); + } else if (tokenNode instanceof MacroDefinitionTokenNode) { + return parseMacroDefinition((MacroDefinitionTokenNode) tokenNode); + } else { + throw new IllegalArgumentException( + "Unexpected token: " + tokenNode.name() + " on line " + tokenNode.lineNumber); + } + } + + private Node parseForEach(ForEachTokenNode forEach) { + Node body = parseTo(END_SET, forEach); + nextNode(); // Skip #end + return new ForEachNode( + forEach.resourceName, forEach.lineNumber, forEach.var, forEach.collection, body); + } + + private Node parseIfOrElseIf(IfOrElseIfTokenNode ifOrElseIf) { + Node truePart = parseTo(ELSE_ELSE_IF_END_SET, ifOrElseIf); + Node falsePart; + Node token = currentNode(); + nextNode(); // Skip #else or #elseif (cond) or #end. + if (token instanceof EndTokenNode) { + falsePart = emptyNode(token.resourceName, token.lineNumber); + } else if (token instanceof ElseTokenNode) { + falsePart = parseTo(END_SET, ifOrElseIf); + nextNode(); // Skip #end + } else if (token instanceof ElseIfTokenNode) { + // We've seen #if (condition1) ... #elseif (condition2). currentToken is the first token + // after (condition2). We pretend that we've just seen #if (condition2) and parse out + // the remainder (which might have further #elseif and final #else). Then we pretend that + // we actually saw #if (condition1) ... #else #if (condition2) ...remainder ... #end #end. + falsePart = parseIfOrElseIf((ElseIfTokenNode) token); + } else { + throw new AssertionError(currentNode()); + } + return new IfNode( + ifOrElseIf.resourceName, ifOrElseIf.lineNumber, ifOrElseIf.condition, truePart, falsePart); + } + + // This is a #parse("foo.vm") directive. We've already done the first phase of parsing on the + // contents of foo.vm. Now we need to do the second phase, and insert the result into the + // reparsed nodes. We can call Reparser recursively, but we must ensure that any macros found + // are added to the containing Reparser's macro definitions. + private Node parseNested(NestedTokenNode nested) { + Reparser reparser = new Reparser(nested.nodes, this.macros); + return reparser.reparseNodes(); + } + + private Node parseMacroDefinition(MacroDefinitionTokenNode macroDefinition) { + Node body = parseTo(END_SET, macroDefinition); + nextNode(); // Skip #end + if (!macros.containsKey(macroDefinition.name)) { + Macro macro = new Macro( + macroDefinition.lineNumber, macroDefinition.name, macroDefinition.parameterNames, body); + macros.put(macroDefinition.name, macro); + } + return emptyNode(macroDefinition.resourceName, macroDefinition.lineNumber); + } + + private void linkMacroCalls() { + for (Node node : nodes) { + if (node instanceof MacroCallNode) { + linkMacroCall((MacroCallNode) node); + } + } + } + + private void linkMacroCall(MacroCallNode macroCall) { + Macro macro = macros.get(macroCall.name()); + if (macro == null) { + throw new ParseException( + "#" + macroCall.name() + + " is neither a standard directive nor a macro that has been defined", + macroCall.resourceName, + macroCall.lineNumber); + } + if (macro.parameterCount() != macroCall.argumentCount()) { + throw new ParseException( + "Wrong number of arguments to #" + macroCall.name() + + ": expected " + macro.parameterCount() + + ", got " + macroCall.argumentCount(), + macroCall.resourceName, + macroCall.lineNumber); + } + macroCall.setMacro(macro); + } +} diff --git a/src/main/java/com/google/escapevelocity/Template.java b/src/main/java/com/google/escapevelocity/Template.java new file mode 100644 index 0000000..646c42b --- /dev/null +++ b/src/main/java/com/google/escapevelocity/Template.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import com.google.escapevelocity.EvaluationContext.PlainEvaluationContext; +import java.io.IOException; +import java.io.Reader; +import java.util.Map; + +/** + * A template expressed in EscapeVelocity, a subset of the Velocity Template Language (VTL) from + * Apache. The intent of this implementation is that if a template is accepted and successfully + * produces output, that output will be identical to what Velocity would have produced for the same + * template and input variables. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +// TODO(emcmanus): spell out exactly what Velocity features are unsupported. +public class Template { + private final Node root; + + /** + * Used to resolve references to resources in the template, through {@code #parse} directives. + * + * <p>Here is an example that opens nested templates as resources relative to the calling class: + * + * <pre> + * ResourceOpener resourceOpener = resourceName -> { + * InputStream inputStream = getClass().getResource(resourceName); + * if (inputStream == null) { + * throw new IOException("Unknown resource: " + resourceName); + * } + * return new BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8)); + * }; + * </pre> + */ + @FunctionalInterface + public interface ResourceOpener { + + /** + * Returns a Reader that will be used to read the given resource, then closed. + * + * @param resourceName the name of the resource to be read. This will never be null. + */ + Reader openResource(String resourceName) throws IOException; + } + + /** + * Parses a VTL template from the given {@code Reader}. The given Reader will be closed on + * return from this method. + */ + public static Template parseFrom(final Reader reader) throws IOException { + ResourceOpener resourceOpener = new ResourceOpener() { + @Override + public Reader openResource(String resourceName) throws IOException { + if (resourceName == null) { + return reader; + } else { + throw new IOException("No ResourceOpener has been configured to read " + resourceName); + } + } + }; + try { + return parseFrom((String) null, resourceOpener); + } finally { + reader.close(); + } + } + + /** + * Parse a VTL template of the given name using the given {@code ResourceOpener}. + * + * @param resourceName name of the resource. May be null. + * @param resourceOpener used to open included files for {@code #parse} directives in the + * template. + */ + public static Template parseFrom( + String resourceName, ResourceOpener resourceOpener) throws IOException { + try (Reader reader = resourceOpener.openResource(resourceName)) { + return new Parser(reader, resourceName, resourceOpener).parse(); + } + } + + Template(Node root) { + this.root = root; + } + + /** + * Evaluate the given template with the given initial set of variables. + * + * @param vars a map where the keys are variable names and the values are the corresponding + * variable values. For example, if {@code "x"} maps to 23, then {@code $x} in the template + * will expand to 23. + * + * @return the string result of evaluating the template. + */ + public String evaluate(Map<String, ?> vars) { + EvaluationContext evaluationContext = new PlainEvaluationContext(vars); + return String.valueOf(root.evaluate(evaluationContext)); + } +} diff --git a/src/main/java/com/google/escapevelocity/TokenNode.java b/src/main/java/com/google/escapevelocity/TokenNode.java new file mode 100644 index 0000000..1e92109 --- /dev/null +++ b/src/main/java/com/google/escapevelocity/TokenNode.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import java.util.List; + +/** + * A parsing node that will be deleted during the construction of the parse tree, to be replaced + * by a higher-level construct such as {@link DirectiveNode.IfNode}. See {@link Parser#parse()} + * for a description of the way these tokens work. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +abstract class TokenNode extends Node { + TokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + /** + * This method always throws an exception because a node like this should never be found in the + * final parse tree. + */ + @Override Object evaluate(EvaluationContext vars) { + throw new UnsupportedOperationException(getClass().getName()); + } + + /** + * The name of the token, for use in parse error messages. + */ + abstract String name(); + + /** + * A synthetic node that represents the end of the input. This node is the last one in the + * initial token string and also the last one in the parse tree. + */ + static final class EofNode extends TokenNode { + EofNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override + String name() { + return "end of file"; + } + } + + static final class EndTokenNode extends TokenNode { + EndTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#end"; + } + } + + /** + * A node in the parse tree representing a comment. Comments are introduced by {@code ##} and + * extend to the end of the line. The only reason for recording comment nodes is so that we can + * skip space between a comment and a following {@code #set}, to be compatible with Velocity + * behaviour. + */ + static class CommentTokenNode extends TokenNode { + CommentTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "##"; + } + } + + abstract static class IfOrElseIfTokenNode extends TokenNode { + final ExpressionNode condition; + + IfOrElseIfTokenNode(ExpressionNode condition) { + super(condition.resourceName, condition.lineNumber); + this.condition = condition; + } + } + + static final class IfTokenNode extends IfOrElseIfTokenNode { + IfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#if"; + } + } + + static final class ElseIfTokenNode extends IfOrElseIfTokenNode { + ElseIfTokenNode(ExpressionNode condition) { + super(condition); + } + + @Override String name() { + return "#elseif"; + } + } + + static final class ElseTokenNode extends TokenNode { + ElseTokenNode(String resourceName, int lineNumber) { + super(resourceName, lineNumber); + } + + @Override String name() { + return "#else"; + } + } + + static final class ForEachTokenNode extends TokenNode { + final String var; + final ExpressionNode collection; + + ForEachTokenNode(String var, ExpressionNode collection) { + super(collection.resourceName, collection.lineNumber); + this.var = var; + this.collection = collection; + } + + @Override String name() { + return "#foreach"; + } + } + + static final class NestedTokenNode extends TokenNode { + final ImmutableList<Node> nodes; + + NestedTokenNode(String resourceName, ImmutableList<Node> nodes) { + super(resourceName, 1); + this.nodes = nodes; + } + + @Override String name() { + return "#parse(\"" + resourceName + "\")"; + } + } + + static final class MacroDefinitionTokenNode extends TokenNode { + final String name; + final ImmutableList<String> parameterNames; + + MacroDefinitionTokenNode( + String resourceName, int lineNumber, String name, List<String> parameterNames) { + super(resourceName, lineNumber); + this.name = name; + this.parameterNames = ImmutableList.copyOf(parameterNames); + } + + @Override String name() { + return "#macro(" + name + ")"; + } + } +} + diff --git a/src/test/java/com/google/escapevelocity/ImmutableSetTest.java b/src/test/java/com/google/escapevelocity/ImmutableSetTest.java new file mode 100644 index 0000000..b0283dd --- /dev/null +++ b/src/test/java/com/google/escapevelocity/ImmutableSetTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class ImmutableSetTest { + @Test + public void empty() { + ImmutableSet<String> empty = ImmutableSet.of(); + assertThat(empty).isEmpty(); + assertThat(empty).doesNotContain(""); + } + + @Test + public void duplicates() { + ImmutableSet<Integer> ints = ImmutableSet.of(1, 2, 3, 2, 1, 2, 3, 3); + assertThat(ints).hasSize(3); + assertThat(ints).containsExactly(1, 2, 3); + + ImmutableSet<Integer> ints2 = ImmutableSet.of(1, 2, 3, 4, 5, 3); + assertThat(ints2).containsExactly(1, 2, 3, 4, 5); + } +} diff --git a/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java new file mode 100644 index 0000000..660c237 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/ReferenceNodeTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Primitives; +import com.google.common.truth.Expect; +import com.google.escapevelocity.ReferenceNode.MethodReferenceNode; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for {@link ReferenceNode}. + * + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class ReferenceNodeTest { + @Rule public Expect expect = Expect.create(); + + // This is the exhaustive list from + // https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2. + // We put the "from" type first for consistency with that list, even though that is inconsistent + // with our method order (which is itself consistent with assignment, "to" on the left). + private static final ImmutableSet<ImmutableList<Class<?>>> ASSIGNMENT_COMPATIBLE = + makeAssignmentCompatibleSet(); + private static ImmutableSet<ImmutableList<Class<?>>> makeAssignmentCompatibleSet() { + Class<?>[][] pairs = { + {byte.class, short.class}, + {byte.class, int.class}, + {byte.class, long.class}, + {byte.class, float.class}, + {byte.class, double.class}, + {short.class, int.class}, + {short.class, long.class}, + {short.class, float.class}, + {short.class, double.class}, + {char.class, int.class}, + {char.class, long.class}, + {char.class, float.class}, + {char.class, double.class}, + {int.class, long.class}, + {int.class, float.class}, + {int.class, double.class}, + {long.class, float.class}, + {long.class, double.class}, + {float.class, double.class}, + }; + ImmutableSet.Builder<ImmutableList<Class<?>>> builder = ImmutableSet.builder(); + for (Class<?>[] pair : pairs) { + builder.add(ImmutableList.copyOf(pair)); + } + return builder.build(); + } + + @Test + public void testPrimitiveTypeIsAssignmentCompatible() { + for (Class<?> from : Primitives.allPrimitiveTypes()) { + for (Class<?> to : Primitives.allPrimitiveTypes()) { + boolean expected = + (from == to || ASSIGNMENT_COMPATIBLE.contains(ImmutableList.of(from, to))); + boolean actual = + MethodReferenceNode.primitiveTypeIsAssignmentCompatible(to, from); + expect + .withMessage(from + " assignable to " + to) + .that(expected).isEqualTo(actual); + } + } + } + + @Test + public void testVisibleMethod() throws Exception { + Map<String, String> map = Collections.singletonMap("foo", "bar"); + Class<?> mapClass = map.getClass(); + assertThat(Modifier.isPublic(mapClass.getModifiers())).isFalse(); + Method size = map.getClass().getMethod("size"); + Method visibleSize = ReferenceNode.visibleMethod(size, mapClass); + assertThat(visibleSize.invoke(map)).isEqualTo(1); + } + + @Test + public void testCompatibleArgs() { + assertThat(MethodReferenceNode.compatibleArgs( + new Class<?>[]{int.class}, ImmutableList.of((Object) 5))).isTrue(); + } +} diff --git a/src/test/java/com/google/escapevelocity/TemplateTest.java b/src/test/java/com/google/escapevelocity/TemplateTest.java new file mode 100644 index 0000000..bd769d6 --- /dev/null +++ b/src/test/java/com/google/escapevelocity/TemplateTest.java @@ -0,0 +1,653 @@ +/* + * Copyright (C) 2015 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.escapevelocity; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.truth.Expect; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.RuntimeInstance; +import org.apache.velocity.runtime.log.NullLogChute; +import org.apache.velocity.runtime.parser.node.SimpleNode; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +@RunWith(JUnit4.class) +public class TemplateTest { + @Rule public TestName testName = new TestName(); + @Rule public Expect expect = Expect.create(); + @Rule public ExpectedException thrown = ExpectedException.none(); + + private RuntimeInstance velocityRuntimeInstance; + + @Before + public void setUp() { + velocityRuntimeInstance = new RuntimeInstance(); + + // Ensure that $undefinedvar will produce an exception rather than outputting $undefinedvar. + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true"); + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, + new NullLogChute()); + + // Disable any logging that Velocity might otherwise see fit to do. + velocityRuntimeInstance.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM, new NullLogChute()); + + velocityRuntimeInstance.init(); + } + + private void compare(String template) { + compare(template, ImmutableMap.<String, Object>of()); + } + + private void compare(String template, Map<String, ?> vars) { + compare(template, Suppliers.ofInstance(vars)); + } + + /** + * Checks that the given template and the given variables produce identical results with + * Velocity and EscapeVelocity. This uses a {@code Supplier} to define the variables to cover + * test cases that involve modifying the values of the variables. Otherwise the run using + * Velocity would change those values so that the run using EscapeVelocity would not be starting + * from the same point. + */ + private void compare(String template, Supplier<? extends Map<String, ?>> varsSupplier) { + Map<String, ?> velocityVars = varsSupplier.get(); + String velocityRendered = velocityRender(template, velocityVars); + Map<String, ?> escapeVelocityVars = varsSupplier.get(); + String escapeVelocityRendered; + try { + escapeVelocityRendered = + Template.parseFrom(new StringReader(template)).evaluate(escapeVelocityVars); + } catch (IOException e) { + throw new AssertionError(e); + } + String failure = "from velocity: <" + velocityRendered + ">\n" + + "from escape velocity: <" + escapeVelocityRendered + ">\n"; + expect.withMessage(failure).that(escapeVelocityRendered).isEqualTo(velocityRendered); + } + + private String velocityRender(String template, Map<String, ?> vars) { + VelocityContext velocityContext = new VelocityContext(new TreeMap<>(vars)); + StringWriter writer = new StringWriter(); + SimpleNode parsedTemplate; + try { + parsedTemplate = velocityRuntimeInstance.parse( + new StringReader(template), testName.getMethodName()); + } catch (org.apache.velocity.runtime.parser.ParseException e) { + throw new AssertionError(e); + } + boolean rendered = velocityRuntimeInstance.render( + velocityContext, writer, parsedTemplate.getTemplateName(), parsedTemplate); + assertThat(rendered).isTrue(); + return writer.toString(); + } + + @Test + public void empty() { + compare(""); + } + + @Test + public void literalOnly() { + compare("In the reign of James the Second \n It was generally reckoned\n"); + } + + @Test + public void comment() { + compare("line 1 ##\n line 2"); + } + + @Test + public void substituteNoBraces() { + compare(" $x ", ImmutableMap.of("x", 1729)); + compare(" ! $x ! ", ImmutableMap.of("x", 1729)); + } + + @Test + public void substituteWithBraces() { + compare("a${x}\nb", ImmutableMap.of("x", "1729")); + } + + @Test + public void substitutePropertyNoBraces() { + compare("=$t.name=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substitutePropertyWithBraces() { + compare("=${t.name}=", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteNestedProperty() { + compare("\n$t.name.empty\n", ImmutableMap.of("t", Thread.currentThread())); + } + + @Test + public void substituteMethodNoArgs() { + compare("<$c.size()>", ImmutableMap.of("c", ImmutableMap.of())); + } + + @Test + public void substituteMethodOneArg() { + compare("<$list.get(0)>", ImmutableMap.of("list", ImmutableList.of("foo"))); + } + + @Test + public void substituteMethodTwoArgs() { + compare("\n$s.indexOf(\"bar\", 2)\n", ImmutableMap.of("s", "barbarbar")); + } + + @Test + public void substituteMethodNoSynthetic() { + // If we aren't careful, we'll see both the inherited `Set<K> keySet()` from Map + // and the overridden `ImmutableSet<K> keySet()` in ImmutableMap. + compare("$map.keySet()", ImmutableMap.of("map", ImmutableMap.of("foo", "bar"))); + } + + @Test + public void substituteIndexNoBraces() { + compare("<$map[\"x\"]>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexWithBraces() { + compare("<${map[\"x\"]}>", ImmutableMap.of("map", ImmutableMap.of("x", "y"))); + } + + @Test + public void substituteIndexThenProperty() { + compare("<$map[2].name>", ImmutableMap.of("map", ImmutableMap.of(2, getClass()))); + } + + @Test + public void variableNameCantStartWithNonAscii() { + compare("<$Éamonn>", ImmutableMap.<String, Object>of()); + } + + @Test + public void variableNamesAreAscii() { + compare("<$Pádraig>", ImmutableMap.of("P", "(P)")); + } + + @Test + public void variableNameCharacters() { + compare("<AZaz-foo_bar23>", ImmutableMap.of("AZaz-foo_bar23", "(P)")); + } + + public static class Indexable { + public String get(String y) { + return "[" + y + "]"; + } + } + + @Test + public void substituteExoticIndex() { + // Any class with a get(X) method can be used with $x[i] + compare("<$x[\"foo\"]>", ImmutableMap.of("x", new Indexable())); + } + + @Test + public void simpleSet() { + compare("$x#set ($x = 17)#set ($y = 23) ($x, $y)", ImmutableMap.of("x", 1)); + } + + @Test + public void newlineAfterSet() { + compare("foo #set ($x = 17)\nbar", ImmutableMap.<String, Object>of()); + } + + @Test + public void newlineInSet() { + compare("foo #set ($x\n = 17)\nbar $x", ImmutableMap.<String, Object>of()); + } + + @Test + public void expressions() { + compare("#set ($x = 1 + 1) $x"); + compare("#set ($x = 1 + 2 * 3) $x"); + compare("#set ($x = (1 + 1 == 2)) $x"); + compare("#set ($x = (1 + 1 != 2)) $x"); + compare("#set ($x = 22 - 7) $x"); + compare("#set ($x = 22 / 7) $x"); + compare("#set ($x = 22 % 7) $x"); + } + + @Test + public void associativity() { + compare("#set ($x = 3 - 2 - 1) $x"); + compare("#set ($x = 16 / 4 / 4) $x"); + } + + @Test + public void precedence() { + compare("#set ($x = 1 + 2 + 3 * 4 * 5 + 6) $x"); + compare("#set($x=1+2+3*4*5+6)$x"); + compare("#set ($x = 1 + 2 * 3 == 3 * 2 + 1) $x"); + } + + @Test + public void and() { + compare("#set ($x = false && false) $x"); + compare("#set ($x = false && true) $x"); + compare("#set ($x = true && false) $x"); + compare("#set ($x = true && true) $x"); + } + + @Test + public void or() { + compare("#set ($x = false || false) $x"); + compare("#set ($x = false || true) $x"); + compare("#set ($x = true || false) $x"); + compare("#set ($x = true || true) $x"); + } + + @Test + public void not() { + compare("#set ($x = !true) $x"); + compare("#set ($x = !false) $x"); + } + + @Test + public void truthValues() { + compare("#set ($x = $true && true) $x", ImmutableMap.of("true", true)); + compare("#set ($x = $false && true) $x", ImmutableMap.of("false", false)); + compare("#set ($x = $emptyCollection && true) $x", + ImmutableMap.of("emptyCollection", ImmutableList.of())); + compare("#set ($x = $emptyString && true) $x", ImmutableMap.of("emptyString", "")); + } + + @Test + public void numbers() { + compare("#set ($x = 0) $x"); + compare("#set ($x = -1) $x"); + compare("#set ($x = " + Integer.MAX_VALUE + ") $x"); + compare("#set ($x = " + Integer.MIN_VALUE + ") $x"); + } + + private static final String[] RELATIONS = {"==", "!=", "<", ">", "<=", ">="}; + + @Test + public void intRelations() { + int[] numbers = {-1, 0, 1, 17}; + for (String relation : RELATIONS) { + for (int a : numbers) { + for (int b : numbers) { + compare("#set ($x = $a " + relation + " $b) $x", + ImmutableMap.<String, Object>of("a", a, "b", b)); + } + } + } + } + + @Test + public void relationPrecedence() { + compare("#set ($x = 1 < 2 == 2 < 1) $x"); + compare("#set ($x = 2 < 1 == 2 < 1) $x"); + } + + /** + * Tests the surprising definition of equality mentioned in + * {@link ExpressionNode.EqualsExpressionNode}. + */ + @Test + public void funkyEquals() { + compare("#set ($t = (123 == \"123\")) $t"); + compare("#set ($f = (123 == \"1234\")) $f"); + compare("#set ($x = ($sb1 == $sb2)) $x", ImmutableMap.of( + "sb1", (Object) new StringBuilder("123"), + "sb2", (Object) new StringBuilder("123"))); + } + + @Test + public void ifTrueNoElse() { + compare("x#if (true)y #end z"); + compare("x#if (true)y #end z"); + compare("x#if (true)y #end\nz"); + compare("x#if (true)y #end\n z"); + compare("x#if (true) y #end\nz"); + compare("x#if (true)\ny #end\nz"); + compare("x#if (true) y #end\nz"); + compare("$x #if (true) y #end $x ", ImmutableMap.of("x", "!")); + } + + @Test + public void ifFalseNoElse() { + compare("x#if (false)y #end z"); + compare("x#if (false)y #end\nz"); + compare("x#if (false)y #end\n z"); + compare("x#if (false) y #end\nz"); + compare("x#if (false)\ny #end\nz"); + compare("x#if (false) y #end\nz"); + } + + @Test + public void ifTrueWithElse() { + compare("x#if (true) a #else b #end z"); + } + + @Test + public void ifFalseWithElse() { + compare("x#if (false) a #else b #end z"); + } + + @Test + public void ifTrueWithElseIf() { + compare("x#if (true) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfTrue() { + compare("x#if (false) a #elseif (true) b #else c #end z"); + } + + @Test + public void ifFalseWithElseIfFalse() { + compare("x#if (false) a #elseif (false) b #else c #end z"); + } + + @Test + public void ifBraces() { + compare("x#{if}(false)a#{elseif}(false)b #{else}c#{end}z"); + } + @Test + public void ifUndefined() { + compare("#if ($undefined) really? #else indeed #end"); + } + + @Test + public void forEach() { + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", new String[] {"foo", "bar", "baz"})); + compare("x#foreach ($x in $c) <$x> #end y", + ImmutableMap.of("c", ImmutableMap.of("foo", "bar", "baz", "buh"))); + } + + @Test + public void forEachHasNext() { + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of())); + compare("x#foreach ($x in $c) <$x#if ($foreach.hasNext), #end> #end y", + ImmutableMap.of("c", ImmutableList.of("foo", "bar", "baz"))); + } + + @Test + public void nestedForEach() { + String template = + "$x #foreach ($x in $listOfLists)\n" + + " #foreach ($y in $x)\n" + + " ($y)#if ($foreach.hasNext), #end\n" + + " #end#if ($foreach.hasNext); #end\n" + + "#end\n" + + "$x\n"; + Object listOfLists = ImmutableList.of( + ImmutableList.of("foo", "bar", "baz"), ImmutableList.of("fred", "jim", "sheila")); + compare(template, ImmutableMap.of("x", 23, "listOfLists", listOfLists)); + } + + @Test + public void forEachScope() { + String template = + "$x #foreach ($x in $list)\n" + + "[$x]\n" + + "#set ($x = \"bar\")\n" + + "#set ($othervar = \"baz\")\n" + + "#end\n" + + "$x $othervar"; + compare( + template, ImmutableMap.of("x", "foo", "list", ImmutableList.of("blim", "blam", "blum"))); + } + + @Test + public void setSpacing() { + // The spacing in the output from #set is eccentric. + compare("x#set ($x = 0)x"); + compare("x #set ($x = 0)x"); + compare("x #set ($x = 0) x"); + compare("$x#set ($x = 0)x", ImmutableMap.of("x", "!")); + + // Velocity WTF: the #set eats the space after $x and other references, so the output is <!x>. + compare("$x #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.length() #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x.empty #set ($x = 0)x", ImmutableMap.of("x", "!")); + compare("$x[0] #set ($x = 0)x", ImmutableMap.of("x", ImmutableList.of("!"))); + + compare("x#set ($x = 0)\n $x!"); + + compare("x #set($x = 0) #set($x = 0) #set($x = 0) y"); + + compare("x ## comment\n #set($x = 0) y"); + } + + @Test + public void simpleMacro() { + String template = + "xyz\n" + + "#macro (m)\n" + + "hello world\n" + + "#end\n" + + "#m() abc #m()\n"; + compare(template); + } + + @Test + public void macroWithArgs() { + String template = + "$x\n" + + "#macro (m $x $y)\n" + + " #if ($x < $y) less #else greater #end\n" + + "#end\n" + + "#m(17 23) #m(23 17) #m(17 17)\n" + + "$x"; + compare(template, ImmutableMap.of("x", "tiddly")); + } + + /** + * Tests defining a macro inside a conditional. This proves that macros are not evaluated in the + * main control flow, but rather are extracted at parse time. It also tests what happens if there + * is more than one definition of the same macro. (It is not apparent from the test, but it is the + * first definition that is retained.) + */ + @Test + public void conditionalMacroDefinition() { + String templateFalse = + "#if (false)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateFalse); + + String templateTrue = + "#if (true)\n" + + " #macro (m) foo #end\n" + + "#else\n" + + " #macro (m) bar #end\n" + + "#end\n" + + "#m()\n"; + compare(templateTrue); + } + + /** + * Tests referencing a macro before it is defined. Since macros are extracted at parse time but + * references are only used at evaluation time, this works. + */ + @Test + public void forwardMacroReference() { + String template = + "#m(17)\n" + + "#macro (m $x)\n" + + " !$x!\n" + + "#end"; + compare(template); + } + + @Test + public void macroArgsSeparatedBySpaces() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0] $list.get(1) 5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + @Test + public void macroArgsSeparatedByCommas() { + String template = + "#macro (sum $x $y $z)\n" + + " #set ($sum = $x + $y + $z)\n" + + " $sum\n" + + "#end\n" + + "#sum ($list[0],$list.get(1),5)\n"; + compare(template, ImmutableMap.of("list", ImmutableList.of(3, 4))); + } + + // The following tests are based on http://wiki.apache.org/velocity/MacroEvaluationStrategy. + // They verify some of the trickier details of Velocity's call-by-name semantics. + + @Test + public void callBySharing() { + // The example on the web page is wrong because $map.put('x', 'a') evaluates to null, which + // Velocity rejects as a render error. We fix this by ensuring that the returned previous value + // is not null. + // Here, the value of $y should not be affected by #set($x = "a"), even though the name passed + // to $x is $y. + String template = + "#macro(callBySharing $x $map)\n" + + " #set($x = \"a\")\n" + + " $map.put(\"x\", \"a\")\n" + + "#end\n" + + "#callBySharing($y $map)\n" + + "y is $y\n" + + "map[x] is $map[\"x\"]\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of( + "y", "y", + "map", new HashMap<String, Object>(ImmutableMap.of("x", (Object) "foo"))); + } + }; + compare(template, makeMap); + } + + @Test + public void callByMacro() { + // Since #callByMacro1 never references its argument, $x.add("t") is never evaluated during it. + // Since #callByMacro2 references its argument twice, $x.add("t") is evaluated twice during it. + String template = + "#macro(callByMacro1 $p)\n" + + " not using\n" + + "#end\n" + + "#macro(callByMacro2 $p)\n" + + " using: $p\n" + + " using again: $p\n" + + " using again: $p\n" + + "#end\n" + + "#callByMacro1($x.add(\"t\"))\n" + + "x = $x\n" + + "#callByMacro2($x.add(\"t\"))\n" + + "x = $x\n"; + Supplier<Map<String, Object>> makeMap = new Supplier<Map<String, Object>>() { + @Override public Map<String, Object> get() { + return ImmutableMap.<String, Object>of("x", new ArrayList<Object>()); + } + }; + compare(template, makeMap); + } + + @Test + public void callByValue() { + // The assignments to the macro parameters $a and $b cause those parameters to be shadowed, + // so the output is: a b becomes b a. + String template = + "#macro(callByValueSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end" + + "#callByValueSwap(\"a\", \"b\")"; + compare(template); + } + + // First "Call by macro expansion example" doesn't apply as long as we don't have map literals. + + @Test + public void nameCaptureSwap() { + // Here, the arguments $a and $b are variables rather than literals, which means that their + // values change when we set those variables. #set($tmp = $a) changes the meaning of $b since + // $b is the name $tmp. So #set($a = $b) shadows parameter $a with the value of $tmp, which we + // have just set to "a". Then #set($b = $tmp) shadows parameter $b also with the value of $tmp. + // The end result is: a b becomes a a. + String template = + "#macro(nameCaptureSwap $a $b)\n" + + " $a $b becomes\n" + + " #set($tmp = $a)\n" + + " #set($a = $b)\n" + + " #set($b = $tmp)\n" + + " $a $b\n" + + "#end\n" + + "#set($x = \"a\")\n" + + "#set($tmp = \"b\")\n" + + "#nameCaptureSwap($x $tmp)"; + compare(template); + } + + @Test + public void undefinedMacro() throws IOException { + String template = "#oops()"; + thrown.expect(ParseException.class); + thrown.expectMessage("#oops is neither a standard directive nor a macro that has been defined"); + Template.parseFrom(new StringReader(template)); + } + + @Test + public void macroArgumentMismatch() throws IOException { + String template = + "#macro (twoArgs $a $b) $a $b #end\n" + + "#twoArgs(23)\n"; + thrown.expect(ParseException.class); + thrown.expectMessage("Wrong number of arguments to #twoArgs: expected 2, got 1"); + Template.parseFrom(new StringReader(template)); + } + +} |