diff --git a/.blazar-enabled b/.blazar-enabled new file mode 100644 index 000000000..e69de29bb diff --git a/.blazar.yaml b/.blazar.yaml new file mode 100644 index 000000000..5727ecbff --- /dev/null +++ b/.blazar.yaml @@ -0,0 +1,2 @@ +buildResources: + memoryMb: 10240 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..10338025d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: ci + +on: [ push, pull_request ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ 17, 21, 25 ] + name: jdk-${{ matrix.java }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-java@v3 + with: + distribution: "temurin" + # The JDK listed last will be the default and what Maven runs with + # https://github.com/marketplace/actions/setup-java-jdk#install-multiple-jdks + java-version: | + ${{ matrix.java }} + 25 + cache: "maven" + - name: "Build" + run: | + mvn \ + --batch-mode \ + -no-transfer-progress \ + -V \ + -Dproject.build.jdk.version=${{ matrix.java }} \ + verify + env: + JDK_JAVA_OPTIONS: --add-opens=java.base/java.lang=ALL-UNNAMED diff --git a/.gitignore b/.gitignore index 98b552ee2..98bd98285 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .DS_Store # Maven +dependency-reduced-pom.xml log/ target/ diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 000000000..c32394f14 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or authors. + * + * 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. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.5"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 000000000..0d5e64988 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..63721ca45 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.2/apache-maven-3.6.2-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar diff --git a/.prettier-java-enabled b/.prettier-java-enabled new file mode 100644 index 000000000..e69de29bb diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6efea5798..000000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: java -jdk: - - oraclejdk8 -install: mvn install -DskipTests=false -Dgpg.skip=true - diff --git a/CHANGES.md b/CHANGES.md index 9ee50d5b3..487dc412a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,533 @@ # Jinjava Releases # +### 2026-07-24 Version 2.8.4 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.8.4/jar)) ### +* [Add support for configurable multi-character delimiters](https://github.com/HubSpot/jinjava/pull/1305) +* [Treat backslash as an escape character only inside quoted strings, matching Jinja2](https://github.com/HubSpot/jinjava/pull/1306) +* [Add `keepTrailingNewline` option to match Python Jinja2's default of stripping a single trailing newline](https://github.com/HubSpot/jinjava/pull/1311) +* [Auto-convert `Integer` to `Long` in set filters when the feature is enabled](https://github.com/HubSpot/jinjava/pull/1308) +* [Add a boolean parameter to `withUnwrapRawOverride`](https://github.com/HubSpot/jinjava/pull/1313) +* [Introduce `BuiltinFeatures`](https://github.com/HubSpot/jinjava/pull/1289) +* [Preserve block tags to maintain reconstruction and execution order](https://github.com/HubSpot/jinjava/pull/1312) +* [Fix `RenderFilter`'s handling of deferred values](https://github.com/HubSpot/jinjava/pull/1258) +* [Use `isResolvableObject` before building a hashcode in eager execution](https://github.com/HubSpot/jinjava/pull/1286) +* [Don't require explicitly pushing the `JinjavaInterpreter` when using it directly](https://github.com/HubSpot/jinjava/pull/1285) + +### 2026-02-02 Version 2.8.3 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.8.3/jar)) ### +* Disallow accessing properties on restricted classes while rendering through ForTag +* Upgrade jackson to version 2.20 +* [Add performance optimization to chained filters](https://github.com/HubSpot/jinjava/pull/1274) + +### 2026-02-02 Version 2.7.6 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.6/jar)) ### +* Disallow accessing properties on restricted classes while rendering through ForTag +* Upgrade jackson to version 2.20 + +### 2025-10-22 Version 2.8.2 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.8.2/jar)) ### +* [Fix helper token escape handling and unescaping when unquoting strings](https://github.com/HubSpot/jinjava/pull/1263) + +### 2025-09-30 Version 2.7.5 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.5/jar)) ### +* Disallow accessing properties on restricted classes while rendering + +### 2025-09-16 Version 2.8.1 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.8.1/jar)) ### +* Disallow accessing properties on restricted classes while rendering +* [Make stack operations use AutoCloseable for safer usage with try-with-resources](https://github.com/HubSpot/jinjava/pull/1250) + +### 2025-05-05 Version 2.8.0 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.8.0/jar)) ### +* [Target Java 17](https://github.com/HubSpot/jinjava/pull/1238) +* [Implement PyMap#get with optional default](https://github.com/HubSpot/jinjava/pull/1233) +* [Fix ConcurrentModificationException when sharing Context across threads](https://github.com/HubSpot/jinjava/pull/1239) +* [Fix max render depth tracking for {% call %} tags](https://github.com/HubSpot/jinjava/pull/1229) + +### 2024-12-06 Version 2.7.4 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.4/jar)) ### +* [Implement jinja2.ext.loopcontrols extensions (break and continue)](https://github.com/HubSpot/jinjava/pull/1219) +* [Apply whitespace rules for LStrip and Trim to comment blocks](https://github.com/HubSpot/jinjava/pull/1217) +### 2024-09-12 Version 2.7.3 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.3/jar)) ### +* [Add support for numeric keys in map literal](https://github.com/HubSpot/jinjava/pull/1152) +* [Add feature to consider undefined variable a TemplateError](https://github.com/HubSpot/jinjava/pull/1174) +* [Improve Optional value serialization](https://github.com/HubSpot/jinjava/pull/1175) +* [Fix bug where extends roots were processed inside of the RenderFilter](https://github.com/HubSpot/jinjava/pull/1159) +* [Don't allow illegal characters in XmlAttrFilter](https://github.com/HubSpot/jinjava/pull/1179) +* [Limit string length in `+` and `~` operators](https://github.com/HubSpot/jinjava/pull/1161) +* Fix various RuntimeExceptions in filters and functions +* Updates dependency versions +### 2024-02-12 Version 2.7.2 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.2/jar)) ### +* [Use interpreter's locale for strftime functions](https://github.com/HubSpot/jinjava/pull/1109) +* [Prevent infinite hashCode recursion in PyList or PyMap](https://github.com/HubSpot/jinjava/pull/1112) +* [Support aliasing macro function names in {% from %} tag](https://github.com/HubSpot/jinjava/pull/1117) +* [Add whitespace trimming functionality for notes and expressions](https://github.com/HubSpot/jinjava/pull/1122) +* [Add feature to prevent accidental expressions from being output](https://github.com/HubSpot/jinjava/pull/1123) +* [Add length-limiting to |render filter and add |closehtml filter](https://github.com/HubSpot/jinjava/pull/1128) +* [Add length-limiting to |tojson filter](https://github.com/HubSpot/jinjava/pull/1131) +* [Make |pprint filter output in JSON format](https://github.com/HubSpot/jinjava/pull/1132) +* [Allow for loop with `null` values](https://github.com/HubSpot/jinjava/pull/1140) +* [Add length-limiting when coercing strings](https://github.com/HubSpot/jinjava/pull/1142) +* [Add `ECHO_UNDEFINED` feature](https://github.com/HubSpot/jinjava/pull/1150) +* Various PRs for eager execution to support two-phase rendering. + +### 2023-08-11 Version 2.7.1 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.1/jar)) ### +* [Introduce `{% do %}` blocks](https://github.com/HubSpot/jinjava/pull/1030) +* [Add warnings for unclosed tokens](https://github.com/HubSpot/jinjava/pull/1093) +* [Fix `|default(null)` behavior](https://github.com/HubSpot/jinjava/pull/1008) +* [Improve EscapeJinjavaFilter](https://github.com/HubSpot/jinjava/pull/1027) +* [Improve BeanELResolver Extensibility](https://github.com/HubSpot/jinjava/pull/1028) +* [Add snake_case serialization config option](https://github.com/HubSpot/jinjava/pull/1031) +* [Add generic node pre/post processors](https://github.com/HubSpot/jinjava/pull/1045) +* [Upgrade jackson to 2.14.0](https://github.com/HubSpot/jinjava/pull/1051) +* [Upgrade jsoup to 1.15.3](https://github.com/HubSpot/jinjava/pull/927) +* [Upgrade guava to 31.1](https://github.com/HubSpot/jinjava/pull/1103) +* [Add feature flags to JinjavaConfig](https://github.com/HubSpot/jinjava/pull/1066) +* [Make restricted methods and properties configurable](https://github.com/HubSpot/jinjava/pull/1076) +* [Gracefully handle invalid escaped quotes](https://github.com/HubSpot/jinjava/pull/1098) +* [Warn when datetime filters use null arguments](https://github.com/HubSpot/jinjava/pull/1064) +* [Fix interpreter scope inside striptags filter](https://github.com/HubSpot/jinjava/pull/1068) +* Fix various RuntimeExceptions in filters and functions +* Various PRs for eager execution to support two-phase rendering. + +### 2023-03-03 Version 2.7.0 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.7.0/jar)) ### +* [Use number operations for multiply and divide filters](https://github.com/HubSpot/jinjava/pull/766) +* [Add config to require whitespace in tokens](https://github.com/HubSpot/jinjava/pull/773) +* [Make reject filter the inverse of select filter](https://github.com/HubSpot/jinjava/pull/790) +* [Make ObjectMapper configurable via JinjavaConfig](https://github.com/HubSpot/jinjava/pull/815) +* [Limit rendering cycle detection to expression nodes](https://github.com/HubSpot/jinjava/pull/817) +* [Add URL decode filter](https://github.com/HubSpot/jinjava/pull/840) +* [Fix truthiness of numbers between 0 and 1](https://github.com/HubSpot/jinjava/pull/857) +* [Fix macro function scoping inside of another macro function](https://github.com/HubSpot/jinjava/pull/869) +* [Handle thread interrupts by throwing an InterpretException](https://github.com/HubSpot/jinjava/pull/870) +* [Fix right-side inline whitespace trimming](https://github.com/HubSpot/jinjava/pull/885) +* [Fix Jinjava functionality for duplicate macro functions and call tags](https://github.com/HubSpot/jinjava/pull/889) +* [Fix custom operator precedence](https://github.com/HubSpot/jinjava/pull/902) +* [Parse leading negatives in expression nodes](https://github.com/HubSpot/jinjava/pull/896) +* [add keys function to dictionary](https://github.com/HubSpot/jinjava/pull/936) +* [Update title filter to ignore special characters](https://github.com/HubSpot/jinjava/pull/945) +* [add unescape_html filter](https://github.com/HubSpot/jinjava/pull/967) +* [Move object unwrap behavior to config object](https://github.com/HubSpot/jinjava/pull/983) +* [Get best invoke method based on parameters](https://github.com/HubSpot/jinjava/pull/996) +* [Create format_number filter](https://github.com/HubSpot/jinjava/pull/999) +* [Get current date and time from a provider](https://github.com/HubSpot/jinjava/pull/1007) +* [Create context method for checking if in for loop](https://github.com/HubSpot/jinjava/pull/1015) +* [Filter duplicate template errors](https://github.com/HubSpot/jinjava/pull/1016) +* Fix various NullPointerExceptions in filters and functions +* Various changes to reduce non-deterministic behavior +* Various changes to improve datetime formatting and exception handling +* Various PRs for eager execution to support two-phase rendering. + +### 2021-10-29 Version 2.6.0 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.6.0/jar)) ### +* [Create interface for object truth values](https://github.com/HubSpot/jinjava/pull/747) +* [Catch concurrent modification in for loop](https://github.com/HubSpot/jinjava/pull/750) +* [Add Originating Exception Message For A TemplateSyntaxException](https://github.com/HubSpot/jinjava/pull/753) +* [Throw a template error when attempting to divide by zero](https://github.com/HubSpot/jinjava/pull/754) +* [Make unixtimestamp behave the same as System.currentTimeMillis()](https://github.com/HubSpot/jinjava/pull/755) +* [handle null argument in range function](https://github.com/HubSpot/jinjava/pull/758) +* [Track Current Processed Node In The Context](https://github.com/HubSpot/jinjava/pull/760) +* [Add Base 64 encode and decode filters](https://github.com/HubSpot/jinjava/pull/763) + +### 2021-09-02 Version 2.5.10 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.5.10/jar)) ### +* [Make LazyExpression memoization disable-able](https://github.com/HubSpot/jinjava/pull/673) +* [Add new MapELResolver with type coercion to support accessing enum keys](https://github.com/HubSpot/jinjava/pull/688) +* Add methods to [remove error from interpreter](https://github.com/HubSpot/jinjava/pull/694), + [get the last error](https://github.com/HubSpot/jinjava/pull/695), + and [remove the last error](https://github.com/HubSpot/jinjava/pull/696) +* [Pass value of throwInterpreterErrors to child contexts](https://github.com/HubSpot/jinjava/pull/697) +* [Support Assignment Blocks with Set tags](https://github.com/HubSpot/jinjava/pull/698) +* [Handle spaces better in for loop expressions](https://github.com/HubSpot/jinjava/pull/706) +* [Support "not in"](https://github.com/HubSpot/jinjava/pull/707) +* [Set propertyResolved after evaluating the AbstractCallableMethod](https://github.com/HubSpot/jinjava/pull/708) +* [Limit infinite evaluation from recursive extends tags](https://github.com/HubSpot/jinjava/pull/719) +* [Fix striptags to clean HTML instead of parsing](https://github.com/HubSpot/jinjava/pull/733) +* Various PRs for eager execution to support two-phase rendering. + +### 2021-05-21 Version 2.5.9 ([Maven Central](https://search.maven.org/artifact/com.hubspot.jinjava/jinjava/2.5.9/jar)) ### +* [fix how current paths are tracked via multiple levels of inheritance with the `{% extends %}` tag](https://github.com/HubSpot/jinjava/pull/667) + +### 2021-05-20 Version 2.5.8 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.8%7Cjar)) ### +* Various PRs for eager execution to support two-phase rendering. +* [Add rangeLimit to JinjavaConfig](https://github.com/HubSpot/jinjava/pull/658) +* [Add namespace functionality](https://github.com/HubSpot/jinjava/pull/649) +* [Fix capitalize and title filters](https://github.com/HubSpot/jinjava/pull/661) + +### 2021-04-09 Version 2.5.7 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.7%7Cjar)) ### +* Various PRs for support EagerTokens and two-phase rendering via ChunkResolver. +* [Preserve Raw Tags Config](https://github.com/HubSpot/jinjava/pull/518) +* [Change config name to preserveForFinalPass](https://github.com/HubSpot/jinjava/pull/520) +* [Ensure that after pushing the interpreter, it gets popped](https://github.com/HubSpot/jinjava/pull/521) +* [Python Booleans and Filter base with parsing](https://github.com/HubSpot/jinjava/pull/523) +* [toyaml/fromyaml filters](https://github.com/HubSpot/jinjava/pull/524) +* [Add ChunkResolver to partially resolve expressions](https://github.com/HubSpot/jinjava/pull/525) +* [Simply JinjavaConfig construction](https://github.com/HubSpot/jinjava/pull/528) +* [Add size limited pymaps and pylists](https://github.com/HubSpot/jinjava/pull/530) +* [Remove overrides for append and insert](https://github.com/HubSpot/jinjava/pull/536) +* [Filter upgrades to support kw params](https://github.com/HubSpot/jinjava/pull/531) +* [Check if list index is numeric before parsing to int](https://github.com/HubSpot/jinjava/pull/538) +* [Rethrow CollectionTooBigExceptions in resolver](https://github.com/HubSpot/jinjava/pull/541) +* [Add error for collection too big](https://github.com/HubSpot/jinjava/pull/550) +* [Fix args for aliased functions](https://github.com/HubSpot/jinjava/pull/551) +* [Add filter to interpret a string early](https://github.com/HubSpot/jinjava/pull/570) +* [Variable function evaluator](https://github.com/HubSpot/jinjava/pull/572) +* [Check that disabled library map isn't null](https://github.com/HubSpot/jinjava/pull/573) +* [Pyish String representations of objects](https://github.com/HubSpot/jinjava/pull/581) +* [Intial support for vsCodeTagSnippets](https://github.com/HubSpot/jinjava/pull/589) +* [Fix documentation for truncate function](https://github.com/HubSpot/jinjava/pull/594) +* [Fix bug with whitespace controls not applying properly](https://github.com/HubSpot/jinjava/pull/599) +* [Allow replace filter on non-strings](https://github.com/HubSpot/jinjava/pull/603) +* [Add function and filter to convert string to date](https://github.com/HubSpot/jinjava/pull/608) +* [Expose jinjava snippets throught the jinjava object](https://github.com/HubSpot/jinjava/pull/622) +* [Output pyish versions of objects using legacy override flag](https://github.com/HubSpot/jinjava/pull/621) +* [Trim before checking if expression is quoted](https://github.com/HubSpot/jinjava/pull/623) +* [Fix tuple parsing bug](https://github.com/HubSpot/jinjava/pull/627) +* [Fix NPE around code snippets documentation](https://github.com/HubSpot/jinjava/pull/630) + +### 2020-10-07 Version 2.5.6 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.6%7Cjar)) ### +* [Accept ip address without network prefix in ipaddr('address') filter](https://github.com/HubSpot/jinjava/pull/512) +* [Expression test parity with jinja including isIterable](https://github.com/HubSpot/jinjava/pull/510) +* [Support IN operator for dictionaries](https://github.com/HubSpot/jinjava/pull/493) +* [Disallow adding a pyMap to itself](https://github.com/HubSpot/jinjava/pull/489) +* [Disallow adding a map to itself](https://github.com/HubSpot/jinjava/pull/474) + +### 2020-06-23 Version 2.5.5 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.5%7Cjar)) ### +* [TagCycleException was thrown when rendering template that doesn't have any cycles](https://github.com/HubSpot/jinjava/pull/445) +* [Make global context thread-safe](https://github.com/HubSpot/jinjava/pull/445) +* [Defer variables used in deferred](https://github.com/HubSpot/jinjava/pull/449) +* [Check for nulls in range function](https://github.com/HubSpot/jinjava/pull/452) +* [Fix for "Equalto operator doesn't work in "or" statement (== does)"](https://github.com/HubSpot/jinjava/pull/455) + +### 2020-05-01 Version 2.5.4 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.4%7Cjar)) ### +* [Remove hacky replaceL behavior](https://github.com/HubSpot/jinjava/pull/407) +* [Add over limit to template errors](https://github.com/HubSpot/jinjava/pull/412) +* [Fix several parse errors](https://github.com/HubSpot/jinjava/pull/413) +* [Add support for Custom Token Scanner Symbols](https://github.com/HubSpot/jinjava/pull/410) +* [Remove print statements from test](https://github.com/HubSpot/jinjava/pull/417) +* [Check for null Config](https://github.com/HubSpot/jinjava/pull/418) +* [Remove reference to TokenScannerSymbols in Nodes and Tokens](https://github.com/HubSpot/jinjava/pull/419) +* [Add to host blacklist for security](https://github.com/HubSpot/jinjava/pull/426) +* [Update blacklist error message copy](https://github.com/HubSpot/jinjava/pull/428) +* [Allow ELResolver to be configured](https://github.com/HubSpot/jinjava/pull/432) +* [Add interpreter to blacklist](https://github.com/HubSpot/jinjava/pull/435) + +### 2020-03-06 Version 2.5.3 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.3%7Cjar)) ### +* [Return empty string for un-evaluated lazy expression](https://github.com/HubSpot/jinjava/pull/405) +* [Add millis precision to unixtimestamp function](https://github.com/HubSpot/jinjava/pull/399) +* [Fix implementation for slice filter](https://github.com/HubSpot/jinjava/pull/398) +* [Implement safe filter as SafeString and handle SafeString in filters, functions and expressions](https://github.com/HubSpot/jinjava/pull/394) +* [Add PyList support to ForTag](https://github.com/HubSpot/jinjava/pull/390) +* [Change DefaultFilter to implement AdvancedFilter](https://github.com/HubSpot/jinjava/pull/389) +* [Adds dict support for DefaultFilter](https://github.com/HubSpot/jinjava/pull/386) +* [Add basic deferred value support for from tag](https://github.com/HubSpot/jinjava/pull/381) +* [Fix template error line numbers](https://github.com/HubSpot/jinjava/pull/380) +* [Track dependencies in FromTag](https://github.com/HubSpot/jinjava/pull/375) +* [Lower logging level for truncate](https://github.com/HubSpot/jinjava/pull/372) +* [Handling for OutputTooBigException](https://github.com/HubSpot/jinjava/pull/371) +* [Serialize lazy expression as its underlying value](https://github.com/HubSpot/jinjava/pull/370) +* [Return image when calling toString for LazyExpression](https://github.com/HubSpot/jinjava/pull/367) +* [More supplier conversions](https://github.com/HubSpot/jinjava/pull/366) +* [Avoid tag cycles when keeping track of parent paths for blocks ](https://github.com/HubSpot/jinjava/pull/363) +* [Add python list operations to PyList](https://github.com/HubSpot/jinjava/pull/362) +* [Fix NPE with lazy expression in intermediate expression resolution](https://github.com/HubSpot/jinjava/pull/358) +* [Create new class that lazily resolves](https://github.com/HubSpot/jinjava/pull/357) +* [Upgrade map filter to advanced filter, improve error messages, and pass through args for filters](https://github.com/HubSpot/jinjava/pull/356) +* [enable more checkstyle rules](https://github.com/HubSpot/jinjava/pull/355) +* [Add codeStyleChecker](https://github.com/HubSpot/jinjava/pull/353) + +### 2019-07-11 Version 2.5.2 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.2%7Cjar)) ### +* [Add type conversion to collection expression tests](https://github.com/HubSpot/jinjava/pull/349) +* [Change initialization of JinjavaInterpreter to instantiation](https://github.com/HubSpot/jinjava/pull/347) +* [Resolve Failure on Unknown Incompatible with default filter](https://github.com/HubSpot/jinjava/pull/345) +* [Add initial support for resolving relative paths](https://github.com/HubSpot/jinjava/pull/343) +* [Add dummy object for validation mode](https://github.com/HubSpot/jinjava/pull/341) +* [Implements equals() and hashCode() methods for TemplateError](https://github.com/HubSpot/jinjava/pull/340) + +### 2019-06-07 Version 2.5.1 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.1%7Cjar)) ### +* [Support more ipaddr filters](https://github.com/HubSpot/jinjava/pull/338) +* [Upgrade to newer basepom](https://github.com/HubSpot/jinjava/pull/334) +* [Support empty bracket implicit index syntax](https://github.com/HubSpot/jinjava/pull/331) +* [Support nominative date formats](https://github.com/HubSpot/jinjava/pull/330) +* [Add a warning for unclosed comments](https://github.com/HubSpot/jinjava/pull/329) +* [Add a warning when there is no matching start tag for an end tag](https://github.com/HubSpot/jinjava/pull/326) +* [Add child dependency to parent dependencies](https://github.com/HubSpot/jinjava/pull/325) +* [Rewrite sort filter to address several problems](https://github.com/HubSpot/jinjava/pull/323) +* [Fix cycle reference during serialization](https://github.com/HubSpot/jinjava/pull/319) +* [Add support for resolving relative paths in separate files](https://github.com/HubSpot/jinjava/pull/316) +* [Return long value from int filter if over max int length](https://github.com/HubSpot/jinjava/pull/315) +* [Use type converter when evaulting 'in'](https://github.com/HubSpot/jinjava/pull/314) +* [Only add max depth error when not in validation mode](https://github.com/HubSpot/jinjava/pull/310) +* [Expand documentation factory with new fields](https://github.com/HubSpot/jinjava/pull/309) +* [Allow ability to set a max recursion depth in config](https://github.com/HubSpot/jinjava/pull/308) + +### 2019-02-05 Version 2.5.0 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.5.0%7Cjar)) ### +* [Render node in include tag in the same interpreter scopes](https://github.com/HubSpot/jinjava/pull/301) +* [Fix expression resolver in include and from tag](https://github.com/HubSpot/jinjava/pull/300) +* [Add root and log filters](https://github.com/HubSpot/jinjava/pull/299) +* [Update expression resolver to return null instead of blank string](https://github.com/HubSpot/jinjava/pull/296) +* [Expression resolver fixed in import tag](https://github.com/HubSpot/jinjava/pull/290) +* [Error and documentation overhaul](https://github.com/HubSpot/jinjava/pull/289) +* [Allow partial evalutation of templates](https://github.com/HubSpot/jinjava/pull/282) + +### 2019-02-05 Version 2.4.15 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.15%7Cjar)) ### +* [Upgrade format filter to advanced filter](https://github.com/HubSpot/jinjava/pull/279) +* [Allow null in string expression tests](https://github.com/HubSpot/jinjava/pull/278) +* [Support negative indices in list slices](https://github.com/HubSpot/jinjava/pull/276) +* [Add max string length configuration](https://github.com/HubSpot/jinjava/pull/275) +* [Removed uses of `Throwables.propagate`](https://github.com/HubSpot/jinjava/pull/272) +* [Allow tags to declare themselves safe for execution in validation mode](https://github.com/HubSpot/jinjava/pull/273) + +### 2019-01-08 Version 2.4.14 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.14%7Cjar)) ### +* [Critical fix for elif statements](https://github.com/HubSpot/jinjava/pull/268) + +### 2019-01-07 Version 2.4.13 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.13%7Cjar)) ### +* [Add support for expressions in selectattr and rejectattr](https://github.com/HubSpot/jinjava/pull/249) +* [Add filters for datetime arithmetic](https://github.com/HubSpot/jinjava/pull/258) +* [Add conversion to Java datetime format for strtotime](https://github.com/HubSpot/jinjava/pull/260) +* [Add set theory filters such as union, intersect and difference](https://github.com/HubSpot/jinjava/pull/262) +* [Add validation mode for extended syntax checking](https://github.com/HubSpot/jinjava/pull/264) +* [Better handling for out of range values in ranage function](https://github.com/HubSpot/jinjava/pull/265) + +### 2018-11-21 Version 2.4.12 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.12%7Cjar)) ### +* [Adds regex_replace filter](https://github.com/HubSpot/jinjava/pull/252) +* [Removes some usage of Java 8 streams to fix a bytecode issue](https://github.com/HubSpot/jinjava/pull/254) + +### 2018-10-23 Version 2.4.11 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.11%7Cjar)) ### +* [Add function for getting start of day](https://github.com/HubSpot/jinjava/pull/247) + +### 2018-10-19 Version 2.4.10 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.10%7Cjar)) ### +* [Support negative array indices](https://github.com/HubSpot/jinjava/pull/245) + +### 2018-10-15 Version 2.4.9 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.9%7Cjar)) ### +* [Add `ipaddr` filter to test valid IP addresses](https://github.com/HubSpot/jinjava/pull/237) +* [Enable nested properties for `selectattr` and `rejectattr`](https://github.com/HubSpot/jinjava/pull/238) +* [Add `do` tag to evaluate expressions without print](https://github.com/HubSpot/jinjava/pull/240) +* [Add support for timezone conversions in `datetimeformat` filter](https://github.com/HubSpot/jinjava/pull/241) +* [Add `prefix` function for `ipaddr` filter](https://github.com/HubSpot/jinjava/pull/243) + +### 2018-09-07 Version 2.4.8 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.8%7Cjar)) ### +* [Bug fix for trunc division and power operations](https://github.com/HubSpot/jinjava/pull/234) + +### 2018-08-31 Version 2.4.7 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.7%7Cjar)) ### +* [Do not allow returning objects of type Class](https://github.com/HubSpot/jinjava/pull/232) + +### 2018-08-29 Version 2.4.6 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.6%7Cjar)) ### + +* [Fix a memory leak in the global context](https://github.com/HubSpot/jinjava/pull/227) +* [Do not allow calling getClass() on objects](https://github.com/HubSpot/jinjava/pull/230) + +### 2018-08-16 Version 2.4.5 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.5%7Cjar)) ### +* [Limit the number errors](https://github.com/HubSpot/jinjava/pull/222) +* [Detect fromTag cycle](https://github.com/HubSpot/jinjava/pull/221) +* [Make jinjajava interpreter render timings trackable](https://github.com/HubSpot/jinjava/pull/219) +* [Add raw object to group in groupby filter](https://github.com/HubSpot/jinjava/pull/218) +* [Register json filters](https://github.com/HubSpot/jinjava/pull/216) +* [Add filter to convert JSON string to Map](https://github.com/HubSpot/jinjava/pull/215) +* [Add filter to convert objects to JSON](https://github.com/HubSpot/jinjava/pull/213) +* [Deepen equalto expression test comparison](https://github.com/HubSpot/jinjava/pull/211) + +### 2018-07-10 Version 2.4.4 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.4%7Cjar)) ### +* [Fix calling macros with kwargs](https://github.com/HubSpot/jinjava/pull/208) +* [Limit the size of strings in TemplateErrors](https://github.com/HubSpot/jinjava/pull/209) + +### 2018-06-13 Version 2.4.3 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.3%7Cjar)) ### +* [Wrap items in for loop with "PyIsh" equivalents](https://github.com/HubSpot/jinjava/pull/202) + +### 2018-06-01 Version 2.4.2 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.2%7Cjar)) ### +* [Upgrade jsoup to address CVE](https://github.com/HubSpot/jinjava/pull/200) + +### 2018-04-22 Version 2.4.1 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.1%7Cjar)) ### + +* [Use `AdvancedFilter` for `selectattr` filter](https://github.com/HubSpot/jinjava/pull/183) +* [Java 9 Support](https://github.com/HubSpot/jinjava/pull/186) +* [Adds negation for expressions ("is not") ](https://github.com/HubSpot/jinjava/pull/187) +* [Fix column numbers in syntax errors](https://github.com/HubSpot/jinjava/pull/188) +* [When reporting errors, preserve casing](https://github.com/HubSpot/jinjava/pull/189) +* [Populate `fieldName` in `TemplateSyntaxException`s](https://github.com/HubSpot/jinjava/pull/190) +* [Reintroduce stricter parsing in int and float filters](https://github.com/HubSpot/jinjava/pull/191) + +### 2018-02-26 Version 2.4.0 ([Maven Central](https://search.maven.org/#artifactdetails%7Ccom.hubspot.jinjava%7Cjinjava%7C2.4.0%7Cjar)) ### + +* [Make int/float parsing locale aware](https://github.com/HubSpot/jinjava/pull/178) + +### 2018-02-09 Version 2.3.6 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.6%22)) ### + +* [Add more sequence expression tests](https://github.com/HubSpot/jinjava/pull/175) +* [Don't put stack trace in the exception message](https://github.com/HubSpot/jinjava/pull/174) + +### 2018-01-26 Version 2.3.5 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.5%22)) ### + +* [Add new EscapeJinjavaFilter](https://github.com/HubSpot/jinjava/pull/168) + +### 2017-11-30 Version 2.3.4 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.4%22)) ### + +* [Preserve groupby order of elements](https://github.com/HubSpot/jinjava/pull/163) + +### 2017-11-16 Version 2.3.3 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.3%22)) ### + +* [always evaluate tags and control structures in nested expressions](https://github.com/HubSpot/jinjava/pull/161) + +### 2017-11-14 Version 2.3.2 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.2%22)) ### + +* [select filter now supports expression tests with arguments like 'equalto'](https://github.com/HubSpot/jinjava/pull/158) +* [`TemplateError`s now include a scope depth](https://github.com/HubSpot/jinjava/pull/157) + +### 2017-10-30 Version 2.3.0 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.3.0%22)) ### + +* Add column numbers to error messages + +### 2017-10-24 Version 2.2.10 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.10%22)) ### + +* Use code of bad syntax as field name for `TemplateSyntaxException`s + +### 2017-08-31 Version 2.2.9 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.9%22)) ### + +* Apply resolved functions, expressions, and values to all [parents of Context object](https://github.com/HubSpot/jinjava/pull/147) + +### 2017-08-12 Version 2.2.8 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.8%22)) ### + +* Prevent recursion in Jinjava. +* Fix failsOnUnknownTokens. +* Add EscapeJson filter. + +### 2017-08-12 Version 2.2.7 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.7%22)) ### + +* Delegate toString() method on PyMap + +### 2017-08-03 Version 2.2.6 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.6%22)) ### + +* Limit size of output when [building strings](https://github.com/HubSpot/jinjava/pull/137) + +### 2017-08-02 Version 2.2.5 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.5%22)) ### + +* Enable configuration of a [non-random number generator](https://github.com/HubSpot/jinjava/pull/135) for tests + +### 2017-08-01 Version 2.2.4 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.4%22)) ### + +* Allow the use of filters including upper case letters: https://github.com/HubSpot/jinjava/pull/132 +* Add function to apply resolved strings from one Context object to another: https://github.com/HubSpot/jinjava/pull/133 + +### 2017-07-21 Version 2.2.3 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.3%22)) ### + +* Make nested expressions configuration default to true. + +### 2017-07-19 Version 2.2.2 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.2%22)) ### + +* Disable interpretation of nested expressions with a configuration. + +### 2017-06-14 Version 2.2.1 ([Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.1%22)) ### + +* Includes field name in unknown tag error + +### 2017-05-12 Version 2.2.0 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.2.0%22)) ### + +* Removes `FileResourceLocator` as a default `ResourceLocator` to close a security hole. See the [README](README.md#template-loading) for details on how to reenable it. + +### 2017-04-11 Version 2.1.19 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.19%22)) ### + +* preserve order of named parameters + +### 2017-04-10 Version 2.1.18 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.18%22)) ### + +* fix bug when passing null argument to `filter` + +### 2017-03-31 Version 2.1.17 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.17%22)) ### + +* added config option to limit the rendered output size +* added named parameter support for filters +* added `type()` function + +### 2017-03-09 Version 2.1.16 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.16%22)) ### + +* disabled functions, filters and tags now add to template errors rather than throwing a fatal exception + +### 2017-01-18 Version 2.1.15 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.15%22)) ### + +* shaded JUEL +* added `failOnUnknownTokens` mode which is similar to Jinja's StrictUndefined + +### 2016-11-18 Version 2.1.14 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.14%22)) ### + +* Enabled manual whitespace control by ending or closing tags with `{%-` or `-%}` +* Fixed issue with passing arguments to `rejectattr` + +### Version 2.1.13 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.13%22)) ### + +* Added support for disabling specific functions, filters and tags + +### Version 2.1.12 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.12%22)) ### + +* Added ** and // operators +* Fixed issue with passing arguments to expression tests + +### Version 2.1.11 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.11%22)) ### + +* Add additional specific error enums +* Add escapeJS filter +* Allow null expressions as target of replace filter + +### Version 2.1.10 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.10%22)) ### + + +### Version 2.1.9 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.9%22)) ### + +* Exclude 'caller' from recursive macro check + +### Version 2.1.8 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.8%22)) ### + +* Add additional category information and error message tokens to TemplateError +* Add range function +* Update ListFilter to work with strings +* Do not allow macros to be called recursively +* Update checkstyle to 2.17 + +### Version 2.1.7 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.7%22)) ### + +* Updated RawTag to not evaluate tags nested within it + +### Version 2.1.6 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.6%22)) ### + +* Added new bool filter to return boolean value from string +* Added record of expressions and values evalulated + +### Version 2.1.5 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.5%22)) ### + +* Add support for java.util.Optional properties, nested properties in EL expressions + +### Version 2.1.4 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.4%22)) ### + +* fixed ArrayIndexOutOfBoundsException when importing template with no trailing newline + +### Version 2.1.3 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.3%22)) ### + +* Added two new expression tests for strings: "is string_startingwith" and "is string_containing" +* Added support for multi-variable set in set tag (@amannm) +* Added new detail dimension to TemplateError: ErrorItem + +### Version 2.1.2 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.2%22)) ### + +* Use resolved path value in include tag cycle detection +* Store autoEscape flag in context outside of user-editable properties +* Store superBlock reference in context outside of user-editable properties +* make EL resolver read-only by default, expose as config parameter +* restrict certain methods/properties in object expressions + +### Version 2.1.1 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.1%22)) ### + +* Better error messages for invalid assignment in expression +* Allow for locale-based date formatting in StrftimeFormatter +* Use configured locale for Functions.datetimeformat + +### Version 2.1.0 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.1.0%22)) ### + +* Refactored node render logic to return richer OutputNode instances, removing a need for a special intermediate string value in text output +* Refactored cycle detection in import and include tags to remove use of special named vars in context +* Fix bug to properly detect cycles in extends tag +* Fix infinite recursion bug in resolveBlockStubs when block contains self-reference + +### Version 2.0.11 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.0.11%22)) ### + +* Renaming JinjavaInterpreter.renderString() to renderFlat() to better signify its purpose +* Released build for jdk7 as version 2.0.11-java7 + +### Version 2.0.10 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.0.10%22)) ### + +* minor performance enhancements ### Version 2.0.9 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.0.9%22)) ### * update truncate_html filter to support preserving words by default, with an additional parameter to chop words at length -* added unique filter to remove duplicate objects from a sequence +* added unique filter to remove duplicate objects from a sequence * add support for global trim_blocks, lstrip_blocks config settings ### Version 2.0.8 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%222.0.8%22)) ### @@ -55,8 +579,8 @@ * 2.0.x requires JDK 8, as it contains some critical fixes to date formatting for certain languages (i.e. Finnish months) * The 2.0.x release has some significant refactorings in the parsing code: -** nests the .parse package under the existing .tree package -** consolidating the token scanner logic, updating the node tree parser + ** nests the .parse package under the existing .tree package + ** consolidating the token scanner logic, updating the node tree parser * future updates will be able to detect more specific template syntax errors than was previously possible. @@ -111,4 +635,3 @@ ### Version 1.0.0 ([Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.hubspot.jinjava%22%20AND%20v%3A%221.0.0%22)) ### * Initial Public Release - diff --git a/README.md b/README.md index cc9310ea9..e1285fa37 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ -jinjava [![Build Status](https://travis-ci.org/HubSpot/jinjava.svg?branch=master)](https://travis-ci.org/HubSpot/jinjava) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.hubspot.jinjava/jinjava/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.hubspot.jinjava/jinjava) -======= +# jinjava + +[![Build Status](https://travis-ci.org/HubSpot/jinjava.svg?branch=master)](https://travis-ci.org/HubSpot/jinjava) +[![Coverage status](https://img.shields.io/codecov/c/github/HubSpot/jinjava/master.svg)](https://codecov.io/github/HubSpot/jinjava) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.hubspot.jinjava/jinjava/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.hubspot.jinjava/jinjava) +[![Join the chat at https://gitter.im/HubSpot/jinjava](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/HubSpot/jinjava?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) jinjava -Java-based template engine based on django template syntax, adapted to render jinja templates (at least the subset of jinja in use in HubSpot content). Currently used in production to render thousands of websites with hundreds of millions of page views per month on the [HubSpot COS](http://www.hubspot.com/products/sites). +Java-based template engine based on django template syntax, adapted to render jinja templates (at least the subset of jinja in use in HubSpot content). Currently used in production to render thousands of websites with hundreds of millions of page views per month on the [HubSpot CMS](http://www.hubspot.com/products/sites). + *Note*: Requires Java >= 8. Originally forked from [jangod](https://code.google.com/p/jangod/). Get it: @@ -13,10 +18,22 @@ Get it: com.hubspot.jinjava jinjava - 2.0.7 + { LATEST_VERSION } ``` +where LATEST_VERSION is the [latest version from CHANGES](CHANGES.md). + +or if you're stuck on java 7: +```xml + + com.hubspot.jinjava + jinjava + 2.0.11-java7 + +``` + + Example usage: -------------- @@ -38,10 +55,10 @@ String renderedTemplate = jinjava.render(template, context); result: ```html -
Hello, Handsome!
+
Hello, Jared!
``` -Voila! Hey, wait a minute... +Voila! Advanced Topics --------------- @@ -53,14 +70,27 @@ Jinjava needs to know how to interpret template paths, so it can properly handle {% extends "foo/bar/base.html" %} ``` -By default, it will load a ```FileLocator```; you will likely want to provide your own implementation of -```ResourceLoader``` to hook into your application's template repository, and then tell jinjava about it: +By default, it will load only a `ClasspathResourceLocator` which will allow loading from ANY file in the classpath inclusing class files. If you want to allow Jinjava to load any file from the +file system, you can add a `FileResourceLocator`. Be aware the security risks of allowing user input to prevent a user +from adding code such as `{% include '/etc/password' %}`. + +You will likely want to provide your own implementation of +`ResourceLoader` to hook into your application's template repository, and then tell jinjava about it: + +```java +JinjavaConfig config = JinjavaConfig.builder().build(); + +Jinjava jinjava = new Jinjava(config); +jinjava.setResourceLocator(new MyCustomResourceLocator()); +``` + +To use more than one `ResourceLocator`, use a `CascadingResourceLocator`. ```java -JinjavaConfig config = new JinjavaConfig(); -config.setResourceLocator(new MyCustomResourceLocator()); +JinjavaConfig config = JinjavaConfig.builder().build(); Jinjava jinjava = new Jinjava(config); +jinjava.setResourceLocator(new MyCustomResourceLocator(), new FileResourceLocator()); ``` ### Custom tags, filters and functions @@ -69,12 +99,15 @@ You can provide custom jinja tags, filters, and static functions to the template ```java // define a custom tag implementing com.hubspot.jinjava.lib.Tag -jinjava.getTagLibrary().addTag(new MyCustomTag()); +jinjava.getGlobalContext().registerTag(new MyCustomTag()); // define a custom filter implementing com.hubspot.jinjava.lib.Filter -jinjava.getFilterLibrary().addFilter(new MyAwesomeFilter()); -// define a custom public static function (this one will bind to myfn.my_func('foo', 42)) -jinjava.getFunctionLibrary().addFunction(new ELFunctionDefinition("myfn", "my_func", +jinjava.getGlobalContext().registerFilter(new MyAwesomeFilter()); +// define a custom public static function (this one will bind to myfn:my_func('foo', 42)) +jinjava.getGlobalContext().registerFunction(new ELFunctionDefinition("myfn", "my_func", MyFuncsClass.class, "myFunc", String.class, Integer.class); + +// define any number of classes which extend Importable +jinjava.getGlobalContext().registerClasses(Class... classes); ``` ### See also diff --git a/benchmark/README.md b/benchmark/README.md deleted file mode 100644 index 1ae12810f..000000000 --- a/benchmark/README.md +++ /dev/null @@ -1,9 +0,0 @@ -Jinjava Benchmarks -================== - -To Run: - - mvn clean package - java -jar target/benchmarks.jar - - diff --git a/benchmark/jinja2 b/benchmark/jinja2 deleted file mode 160000 index 85820fceb..000000000 --- a/benchmark/jinja2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 85820fceb83569df62fa5e6b9b0f2f76b7c6a3cf diff --git a/benchmark/liquid b/benchmark/liquid deleted file mode 160000 index e2f8b28f5..000000000 --- a/benchmark/liquid +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e2f8b28f56296ec767eb225be0ddf36f01016613 diff --git a/benchmark/pom.xml b/benchmark/pom.xml deleted file mode 100644 index 70d78cc99..000000000 --- a/benchmark/pom.xml +++ /dev/null @@ -1,180 +0,0 @@ - - - - 4.0.0 - - com.hubspot.content - jinjava-benchmark - 1.0 - jar - - JMH benchmark sample: Java - - - - - 3.0 - - - - - com.hubspot.jinjava - jinjava - 2.0.8-SNAPSHOT - - - commons-io - commons-io - 2.4 - - - org.slf4j - slf4j-api - 1.7.6 - - - ch.qos.logback - logback-classic - 1.1.2 - - - org.yaml - snakeyaml - 1.14 - - - de.sven-jacobs - loremipsum - 1.0 - - - - org.openjdk.jmh - jmh-core - ${jmh.version} - - - org.openjdk.jmh - jmh-generator-annprocess - ${jmh.version} - provided - - - - - UTF-8 - 1.10.3 - 1.8 - benchmarks - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.1 - - ${javac.target} - ${javac.target} - ${javac.target} - - - - org.apache.maven.plugins - maven-shade-plugin - 2.2 - - - package - - shade - - - ${uberjar.name} - - - org.openjdk.jmh.Main - - - - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - - - - - - maven-clean-plugin - 2.5 - - - maven-deploy-plugin - 2.8.1 - - - maven-install-plugin - 2.5.1 - - - maven-jar-plugin - 2.4 - - - maven-javadoc-plugin - 2.9.1 - - - maven-resources-plugin - 2.6 - - - maven-site-plugin - 3.3 - - - maven-source-plugin - 2.2.1 - - - maven-surefire-plugin - 2.17 - - - - - - diff --git a/benchmark/resources/jinja/simple.jinja b/benchmark/resources/jinja/simple.jinja deleted file mode 100644 index d60f09782..000000000 --- a/benchmark/resources/jinja/simple.jinja +++ /dev/null @@ -1,27 +0,0 @@ - - - - {{page_title|e}} - - -
-

{{page_title|e}}

-
- -
- - {% for row in table %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} -
{{cell}}
-
- - diff --git a/benchmark/resources/logback.xml b/benchmark/resources/logback.xml deleted file mode 100644 index 859cb540e..000000000 --- a/benchmark/resources/logback.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Article.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Article.java deleted file mode 100644 index aacdab60b..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Article.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.hubspot.jinjava.benchmarks.jinja2; - -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.Date; - -import de.svenjacobs.loremipsum.LoremIpsum; - -public class Article { - - private int id; - private String href; - private String title; - private User user; - private String body; - private Date pubDate; - private boolean published; - - public Article(int id, User user) throws NoSuchAlgorithmException { - this.id = id; - this.href = "/article/" + id; - - LoremIpsum ipsum = new LoremIpsum(); - SecureRandom rnd = SecureRandom.getInstanceStrong(); - - this.title = ipsum.getWords(10); - this.user = user; - this.body = ipsum.getParagraphs(); - this.pubDate = Date.from(LocalDateTime.now().minusHours(rnd.nextInt(128)).toInstant(ZoneOffset.UTC)); // TODO randomize - this.published = true; - } - - public int getId() { - return id; - } - - public String getHref() { - return href; - } - - public String getTitle() { - return title; - } - - public User getUser() { - return user; - } - - public String getBody() { - return body; - } - - public Date getPubDate() { - return pubDate; - } - - public boolean isPublished() { - return published; - } - -} diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Jinja2Benchmark.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Jinja2Benchmark.java deleted file mode 100644 index 236d37ad3..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/Jinja2Benchmark.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.hubspot.jinjava.benchmarks.jinja2; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.slf4j.LoggerFactory; - -import ch.qos.logback.classic.Level; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.loader.FileLocator; -import com.hubspot.jinjava.loader.ResourceLocator; -import com.hubspot.jinjava.tree.Node; - - -@State(Scope.Benchmark) -public class Jinja2Benchmark { - - public String complexTemplate; - public Map complexBindings; - - public Jinjava jinjava; - - public JinjavaInterpreter interpreter; - public Node precompiledTemplate; - - @SuppressWarnings("unchecked") - @Setup - public void setup() throws IOException, NoSuchAlgorithmException { - ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); - logger.setLevel(Level.WARN); - - jinjava = new Jinjava(); - interpreter = jinjava.newInterpreter(); - - FileLocator locator = new FileLocator(new File("jinja2/examples/rwbench/jinja")); - final String helpersTemplate = locator.getString("helpers.html", StandardCharsets.UTF_8, interpreter); - final String indexTemplate = locator.getString("index.html", StandardCharsets.UTF_8, interpreter); - final String layoutTemplate = locator.getString("layout.html", StandardCharsets.UTF_8, interpreter); - - jinjava.setResourceLocator(new ResourceLocator() { - @Override - public String getString(String fullName, Charset encoding, JinjavaInterpreter interpreter) throws IOException { - switch(fullName) { - case "helpers.html": - return helpersTemplate; - case "layout.html": - return layoutTemplate; - case "index.html": - return indexTemplate; - } - return null; - } - }); - - complexTemplate = indexTemplate; - // for tag doesn't support postfix conditional filtering - complexTemplate = complexTemplate.replaceAll(" if article.published", ""); - - List users = Lists.newArrayList(new User("John Doe"), new User("Jane Doe"), new User("Peter Somewhat")); - SecureRandom rnd = SecureRandom.getInstanceStrong(); - List
articles = new ArrayList<>(); - for(int i = 0; i < 20; i++) { - articles.add(new Article(i, users.get(rnd.nextInt(users.size())))); - } - List> navigation = Lists.newArrayList( - Lists.newArrayList("index", "Index"), - Lists.newArrayList("about", "About"), - Lists.newArrayList("foo?bar=1", "Foo with Bar"), - Lists.newArrayList("foo?bar=2&s=x", "Foo with X"), - Lists.newArrayList("blah", "Blub Blah"), - Lists.newArrayList("hehe", "Haha") - ); - - complexBindings = ImmutableMap.of("users", users, "articles", articles, "navigation", navigation); - - precompiledTemplate = interpreter.parse(complexTemplate); - } - - @Benchmark - public String realWorldishBenchmark() { - return jinjava.render(complexTemplate, complexBindings); - } - - @Benchmark - public String precompiledBenchmark() { - return interpreter.render(precompiledTemplate, true); - } - - public static void main(String[] args) throws Exception { - Jinja2Benchmark b = new Jinja2Benchmark(); - b.setup(); - System.out.println(b.realWorldishBenchmark()); - System.out.println(b.precompiledBenchmark()); - } - -} diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/User.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/User.java deleted file mode 100644 index df0641b95..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/jinja2/User.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.hubspot.jinjava.benchmarks.jinja2; - -public class User { - - private String href; - private String username; - - public User(String username) { - this.href = "/user/" + username; - this.username = username; - } - - public String getHref() { - return href; - } - - public String getUsername() { - return username; - } - -} diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Filters.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Filters.java deleted file mode 100644 index 492543196..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Filters.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.hubspot.jinjava.benchmarks.liquid; - -import static org.apache.commons.lang3.math.NumberUtils.toDouble; - -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; - -import org.apache.commons.lang3.StringUtils; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.filter.DatetimeFilter; -import com.hubspot.jinjava.lib.filter.Filter; -import com.hubspot.jinjava.lib.fn.Functions; - -/** - * Liquid::Template.register_filter JsonFilter - * Liquid::Template.register_filter MoneyFilter - * Liquid::Template.register_filter WeightFilter - * Liquid::Template.register_filter ShopFilter - * Liquid::Template.register_filter TagFilter - * - * @author jstehler - * - */ -public class Filters { - - /** - * override date filter to match liquid functionality - */ - public static class OverrideDateFilter extends DatetimeFilter { - @Override - public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - return Functions.dateTimeFormat(ZonedDateTime.now(), arg); - } - } - - public static class JsonFilter implements Filter { - @Override - public String getName() { - return "json"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return null; - } - } - - public static class MoneyFilter implements Filter { - @Override - public String getName() { - return "money"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - if(var == null) { - return ""; - } - - double money = toDouble(Objects.toString(var)); - return String.format("$ %.2f", money / 100.0); - } - } - - public static class MoneyWithCurrencyFilter extends MoneyFilter { - @Override - public String getName() { - return "money_with_currency"; - } - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - Object val = super.filter(var, interpreter, args); - if(val.toString().length() == 0) { - return ""; - } - return val + " USD"; - } - } - - public static class WeightFilter implements Filter { - @Override - public String getName() { - return "weight"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - double grams = toDouble(Objects.toString(var)); - return String.format("%.2f", grams / 1000); - } - } - - public static class WeightWithUnitFilter extends WeightFilter { - @Override - public String getName() { - return "weight_with_unit"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return super.filter(var, interpreter, args) + " kg"; - } - } - - public static class ShopAssetUrlFilter implements Filter { - @Override - public String getName() { - return "asset_url"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return "/files/1/[shop_id]/[shop_id]/assets/" + var; - } - } - - public static class ShopGlobalAssetUrl implements Filter { - @Override - public String getName() { - return "global_asset_url"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return "/global/" + var; - } - } - - public static class ShopShopifyAssetUrl implements Filter { - @Override - public String getName() { - return "global_asset_url"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return "/shopify/" + var; - } - } - - public static class ShopScriptTag implements Filter { - @Override - public String getName() { - return "script_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - return String.format("", var); - } - } - - public static class ShopStylesheetTag implements Filter { - @Override - public String getName() { - return "stylesheet_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String media = "all"; - if(args.length > 0) { - media = args[0]; - } - return String.format("", var, media); - } - } - - public static class ShopLinkTo implements Filter { - @Override - public String getName() { - return "link_to"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String url = args[0]; - String title = ""; - if(args.length > 1) { - title = args[1]; - } - return String.format("%s", url, title, var); - } - } - - public static class ShopImgTagFilter implements Filter { - @Override - public String getName() { - return "img_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String alt = ""; - if(args.length > 0) { - alt = args[0]; - } - - return String.format("\"%s\"", var, alt); - } - } - - public static class LinkToTagFilter implements Filter { - @Override - public String getName() { - return "link_to_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String label = Objects.toString(var); - String tag = args[0]; - - return String.format("%s", tag, interpreter.getContext().get("handle"), tag, label); - } - } - - public static class HighlightActiveTagFilter implements Filter { - @Override - public String getName() { - return "highlight_active_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String tag = Objects.toString(var); - String cssClass = "active"; - if(args.length > 0) { - cssClass = args[0]; - } - - Collection currentTags = getCurrentTags(interpreter); - if(currentTags.contains(tag)) { - return String.format("%s", cssClass, tag); - } - else { - return tag; - } - } - } - - public static class LinkToAddTagFilter implements Filter { - @Override - public String getName() { - return "link_to_add_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String label = Objects.toString(var); - String tag = args[0]; - - Set tags = new TreeSet(getCurrentTags(interpreter)); - tags.add(tag); - - return String.format("%s", - tag, interpreter.getContext().get("handle"), StringUtils.join(tags, '+'), label); - } - } - - public static class LinkToRemoveTagFilter implements Filter { - @Override - public String getName() { - return "link_to_remove_tag"; - } - - @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String label = Objects.toString(var); - String tag = args[0]; - - Set tags = new TreeSet(getCurrentTags(interpreter)); - tags.remove(tag); - - return String.format("%s", - tag, interpreter.getContext().get("handle"), StringUtils.join(tags, '+'), label); - } - } - - @SuppressWarnings("unchecked") - private static Collection getCurrentTags(JinjavaInterpreter interpreter) { - Collection currentTags = (Collection) interpreter.getContext().get("current_tags", new ArrayList()); - return currentTags; - } - -} diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/LiquidBenchmark.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/LiquidBenchmark.java deleted file mode 100644 index 825fef535..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/LiquidBenchmark.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.hubspot.jinjava.benchmarks.liquid; - -import static org.apache.commons.io.FileUtils.listFiles; -import static org.apache.commons.io.FileUtils.readFileToString; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.infra.Blackhole; -import org.slf4j.LoggerFactory; -import org.yaml.snakeyaml.Yaml; - -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.tree.Node; - -import ch.qos.logback.classic.Level; - -@State(Scope.Benchmark) -public class LiquidBenchmark { - - public List templates; - public Map bindings; - - public Jinjava jinjava; - - @SuppressWarnings("unchecked") - @Setup - public void setup() throws IOException { - ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); - logger.setLevel(Level.WARN); - - jinjava = new Jinjava(); - - jinjava.getGlobalContext().registerClasses( - Filters.OverrideDateFilter.class, - - Filters.JsonFilter.class, - Filters.LinkToAddTagFilter.class, - Filters.LinkToRemoveTagFilter.class, - Filters.LinkToTagFilter.class, - Filters.HighlightActiveTagFilter.class, - Filters.MoneyFilter.class, - Filters.MoneyWithCurrencyFilter.class, - Filters.ShopAssetUrlFilter.class, - Filters.ShopGlobalAssetUrl.class, - Filters.ShopImgTagFilter.class, - Filters.ShopLinkTo.class, - Filters.ShopScriptTag.class, - Filters.ShopShopifyAssetUrl.class, - Filters.ShopStylesheetTag.class, - Filters.WeightFilter.class, - Filters.WeightWithUnitFilter.class, - - Tags.AssignTag.class, - Tags.CommentFormTag.class, - Tags.PaginateTag.class, - Tags.TableRowTag.class); - - templates = new ArrayList<>(); - - Map db = (Map) new Yaml().load(readFileToString(new File("liquid/performance/shopify/vision.database.yml"), StandardCharsets.UTF_8)); - bindings = new HashMap<>(initDb(db)); - - File baseDir = new File("liquid/performance/tests"); - for (File tmpl : listFiles(baseDir, new String[] { "liquid" }, true)) { - - String template = readFileToString(tmpl, StandardCharsets.UTF_8); - // convert filter syntax from ':' to '()' - template = template.replaceAll("\\| ([\\w_]+): (.*?)(\\||})", "| $1($2)$3"); - // jinjava doesn't have the '?' postfix binary operator - template = template.replaceAll("if (.*?)\\?", "if $1"); - // no support for offset:n - template = template.replaceAll("offset:\\s*\\d*", ""); - // no support for limit:n - template = template.replaceAll("limit:\\s*\\d*", ""); - // no support for cols:n - template = template.replaceAll("cols:\\s*\\d*", ""); - // no support for for reversal - template = template.replaceAll(" reversed", ""); - - // System.out.println("Adding template: " + tmpl.getAbsolutePath()); - // System.out.println(template); - - templates.add(template); - } - } - - @SuppressWarnings("unchecked") - private Map initDb(Map db) { - for (Map.Entry entry : db.entrySet()) { - if (entry.getValue() instanceof List) { - List values = (List) entry.getValue(); - - if (values.size() > 0 && values.get(0) instanceof Map) { - Map byHandle = new HashMap<>(); - - for (Map val : (List>) values) { - String handle = val.getOrDefault("handle", "").toString(); - - if (handle.trim().length() > 0) { - byHandle.put(handle, val); - } - } - - if (byHandle.size() > 0) { - db.put(entry.getKey(), byHandle); - } - } - } - } - - return db; - } - - @Benchmark - public void parse(Blackhole blackhole) { - JinjavaInterpreter interpreter = jinjava.newInterpreter(); - - for (String template : templates) { - Node parsed = interpreter.parse(template); - if (blackhole != null) { - blackhole.consume(parsed); - } - } - } - - @Benchmark - public void parseAndRender(Blackhole blackhole) { - for (String template : templates) { - String result = jinjava.render(template, bindings); - if (blackhole != null) { - blackhole.consume(result); - } - } - } - - public static void main(String[] args) throws Exception { - LiquidBenchmark b = new LiquidBenchmark(); - b.setup(); - b.parse(null); - b.parseAndRender(null); - } - -} diff --git a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Tags.java b/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Tags.java deleted file mode 100644 index a27474bb4..000000000 --- a/benchmark/src/main/java/com/hubspot/jinjava/benchmarks/liquid/Tags.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.hubspot.jinjava.benchmarks.liquid; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.tag.SetTag; -import com.hubspot.jinjava.lib.tag.Tag; -import com.hubspot.jinjava.tree.TagNode; - -/** - * Liquid::Template.register_tag 'paginate', Paginate - * Liquid::Template.register_tag 'form', CommentForm - * - * @author jstehler - * - */ -public class Tags { - - public static class PaginateTag implements Tag { - private static final long serialVersionUID = -4143036883302838710L; - - @Override - public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - return null; - } - - @Override - public String getName() { - return "paginate"; - } - - @Override - public String getEndTagName() { - return "endpaginate"; - } - } - - public static class CommentFormTag implements Tag { - private static final long serialVersionUID = 4740110980519195813L; - - @Override - public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - - return null; - } - - @Override - public String getName() { - return "form"; - } - - @Override - public String getEndTagName() { - return "endform"; - } - } - - public static class AssignTag extends SetTag { - private static final long serialVersionUID = -8045822376271136191L; - - @Override - public String getName() { - return "assign"; - } - } - - public static class TableRowTag implements Tag { - private static final long serialVersionUID = 7058892410901688159L; - - @Override - public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - // TODO Auto-generated method stub - return null; - } - - @Override - public String getName() { - return "tablerow"; - } - - @Override - public String getEndTagName() { - return "endtablerow"; - } - } - -} diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 000000000..36247320d --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mvnw b/mvnw new file mode 100755 index 000000000..d2f0ea380 --- /dev/null +++ b/mvnw @@ -0,0 +1,310 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 000000000..35c0e6597 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml index f703d473e..3ddbc2c9b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,38 +1,117 @@ + 4.0.0 com.hubspot basepom - 10.4 + 68.1 com.hubspot.jinjava jinjava - 2.0.10-SNAPSHOT + 3.0.0-SNAPSHOT + + ${project.groupId}:${project.artifactId} Jinja templating engine implemented in Java - https://github.com/HubSpot/jinjava - - scm:git:git@github.com:HubSpot/jinjava.git - scm:git:git@github.com:HubSpot/jinjava.git - git@github.com:HubSpot/jinjava.git - HEAD - + + 17 - - - jaredstehler - Jared Stehler - jstehler@hubspot.com - - + 0.8.3 + 3.0.1 + 1.11.4 + 1.7.2 + 5.20.0 + 2.20.1 - - 1.8 - false + + --add-opens=java.base/java.lang=ALL-UNNAMED + + + + + + + org.jsoup + jsoup + 1.15.3 + + + de.odysseus.juel + juel-api + 2.2.7 + + + de.odysseus.juel + juel-impl + 2.2.7 + + + com.google.re2j + re2j + 1.2 + + + ch.obermuhlner + big-math + 2.0.0 + + + commons-net + commons-net + 3.9.0 + + + com.googlecode.java-ipv6 + java-ipv6 + 0.17 + + + com.fasterxml.jackson.core + jackson-annotations + 2.20 + + + com.fasterxml.jackson.core + jackson-databind + ${dep.jackson.version} + + + com.fasterxml.jackson.core + jackson-core + ${dep.jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${dep.jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${dep.jackson.version} + + + com.hubspot.immutables + hubspot-style + ${dep.hubspot-immutables.version} + + + com.hubspot.immutables + immutables-exceptions + ${dep.hubspot-immutables.version} + + + com.hubspot + algebra + ${dep.algebra.version} + + + + org.slf4j @@ -49,32 +128,64 @@ org.jsoup jsoup - 1.8.1 de.odysseus.juel juel-api - 2.2.7 de.odysseus.juel juel-impl - 2.2.7 - de.odysseus.juel - juel-spi - 2.2.7 - runtime + com.google.re2j + re2j org.apache.commons commons-lang3 + + commons-net + commons-net + + + com.googlecode.java-ipv6 + java-ipv6 + com.google.code.findbugs annotations + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + ch.obermuhlner + big-math + + + com.google.errorprone + error_prone_annotations + runtime + ch.qos.logback @@ -101,6 +212,176 @@ mockito-core test + + org.immutables + value + provided + + + com.hubspot + algebra + + - + + + + org.jacoco + jacoco-maven-plugin + + + + prepare-agent + + + + report + + report + + test + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + validate + + check + + validate + + checkstyle.xml + UTF-8 + true + true + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + spotbugs-exclude-filter.xml + + + + org.apache.maven.plugins + maven-shade-plugin + + + + + + shade + + package + + true + false + + + de.odysseus.juel:juel-api + de.odysseus.juel:juel-impl + org.jsoup:jsoup + + + + + javax.el + jinjava.javax.el + + + de.odysseus.el + jinjava.de.odysseus.el + + + org.jsoup + jinjava.org.jsoup + + + + + + ${project.build.targetJdk} + + + + + + + + + org.basepom.maven + duplicate-finder-maven-plugin + + + module-info + META-INF.versions.9.module-info + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} ${basepom.test.add.opens} + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.immutables + value + + + + + + + + https://github.com/HubSpot/jinjava + + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + jaredstehler + Jared Stehler + + + boulter + Jeff Boulter + + + + + scm:git:git@github.com:HubSpot/jinjava.git + scm:git:git@github.com:HubSpot/jinjava.git + git@github.com:HubSpot/jinjava.git + HEAD$ + + + + + basepom.central-release + + true + true + + + + diff --git a/spotbugs-exclude-filter.xml b/spotbugs-exclude-filter.xml new file mode 100644 index 000000000..94e88a3c1 --- /dev/null +++ b/spotbugs-exclude-filter.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/com/hubspot/jinjava/Jinjava.java b/src/main/java/com/hubspot/jinjava/Jinjava.java index e0e7b731f..f3ac8a4fb 100644 --- a/src/main/java/com/hubspot/jinjava/Jinjava.java +++ b/src/main/java/com/hubspot/jinjava/Jinjava.java @@ -15,33 +15,36 @@ **********************************************************************/ package com.hubspot.jinjava; -import java.util.Collection; -import java.util.Map; -import java.util.Properties; - -import javax.el.ExpressionFactory; - -import com.google.common.base.Predicate; -import com.google.common.collect.Collections2; import com.hubspot.jinjava.doc.JinjavaDoc; import com.hubspot.jinjava.doc.JinjavaDocFactory; import com.hubspot.jinjava.el.ExtendedSyntaxBuilder; import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.el.ext.eager.EagerExtendedSyntaxBuilder; import com.hubspot.jinjava.interpret.Context; import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.interpret.RenderResult; import com.hubspot.jinjava.interpret.TemplateError; import com.hubspot.jinjava.interpret.TemplateError.ErrorType; -import com.hubspot.jinjava.loader.CascadingResourceLocator; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.exptest.ExpTest; +import com.hubspot.jinjava.lib.filter.Filter; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.lib.tag.Tag; import com.hubspot.jinjava.loader.ClasspathResourceLocator; -import com.hubspot.jinjava.loader.FileLocator; import com.hubspot.jinjava.loader.ResourceLocator; - import de.odysseus.el.ExpressionFactoryImpl; import de.odysseus.el.misc.TypeConverter; import de.odysseus.el.tree.TreeBuilder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; +import javax.el.ExpressionFactory; /** * The main client API for the Jinjava library, instances of this class can be used to render jinja templates with a given map of context values. Example use: @@ -60,6 +63,7 @@ public class Jinjava { private ExpressionFactory expressionFactory; + private ExpressionFactory eagerExpressionFactory; private ResourceLocator resourceLocator; private Context globalContext; @@ -69,7 +73,7 @@ public class Jinjava { * Create a new Jinjava processor instance with the default global config */ public Jinjava() { - this(new JinjavaConfig()); + this(JinjavaConfig.builder().build()); } /** @@ -83,12 +87,24 @@ public Jinjava(JinjavaConfig globalConfig) { this.globalContext = new Context(); Properties expConfig = new Properties(); - expConfig.setProperty(TreeBuilder.class.getName(), ExtendedSyntaxBuilder.class.getName()); + + expConfig.setProperty( + TreeBuilder.class.getName(), + ExtendedSyntaxBuilder.class.getName() + ); + Properties eagerExpConfig = new Properties(); + + eagerExpConfig.setProperty( + TreeBuilder.class.getName(), + EagerExtendedSyntaxBuilder.class.getName() + ); + eagerExpConfig.setProperty(ExpressionFactoryImpl.PROP_CACHE_SIZE, "0"); TypeConverter converter = new TruthyTypeConverter(); this.expressionFactory = new ExpressionFactoryImpl(expConfig, converter); + this.eagerExpressionFactory = new ExpressionFactoryImpl(eagerExpConfig, converter); - this.resourceLocator = new CascadingResourceLocator(new ClasspathResourceLocator(), new FileLocator()); + this.resourceLocator = new ClasspathResourceLocator(); } /** @@ -108,6 +124,13 @@ public ExpressionFactory getExpressionFactory() { return expressionFactory; } + /** + * @return The EL factory used to eagerly process expressions in templates by this instance. + */ + public ExpressionFactory getEagerExpressionFactory() { + return eagerExpressionFactory; + } + /** * @return The global config used as a base for all render operations performed by this instance. */ @@ -124,6 +147,10 @@ public Context getGlobalContext() { return globalContext; } + public Context getGlobalContextCopy() { + return copyGlobalContext(); + } + public ResourceLocator getResourceLocator() { return resourceLocator; } @@ -135,6 +162,13 @@ public JinjavaDoc getJinjavaDoc() { return new JinjavaDocFactory(this).get(); } + /** + * @return code snippets of all available filters, functions, and tags registered on this jinjava instance. + */ + public String getJinjavaSnippetDoc() { + return new JinjavaDocFactory(this).getCodeEditorTagSnippets(); + } + /** * Render the given template using the given context bindings. * @@ -149,12 +183,11 @@ public JinjavaDoc getJinjavaDoc() { public String render(String template, Map bindings) { RenderResult result = renderForResult(template, bindings); - Collection fatalErrors = Collections2.filter(result.getErrors(), new Predicate() { - @Override - public boolean apply(TemplateError input) { - return input.getSeverity() == ErrorType.FATAL; - } - }); + List fatalErrors = result + .getErrors() + .stream() + .filter(error -> error.getSeverity() == ErrorType.FATAL) + .collect(Collectors.toList()); if (!fatalErrors.isEmpty()) { throw new FatalTemplateErrorsException(template, fatalErrors); @@ -189,36 +222,136 @@ public RenderResult renderForResult(String template, Map bindings) { * used to override specific config values for this render operation * @return result object containing rendered output, render context, and any encountered errors */ - public RenderResult renderForResult(String template, Map bindings, JinjavaConfig renderConfig) { - Context context = new Context(globalContext, bindings); - + public RenderResult renderForResult( + String template, + Map bindings, + JinjavaConfig renderConfig + ) { + Context context; JinjavaInterpreter parentInterpreter = JinjavaInterpreter.getCurrent(); if (parentInterpreter != null) { renderConfig = parentInterpreter.getConfig(); + Map bindingsWithParentContext = createBindingsWithParentContext( + bindings, + parentInterpreter.getContext() + ); + context = + new Context( + copyGlobalContext(), + bindingsWithParentContext, + renderConfig.getDisabled() + ); + } else { + context = new Context(copyGlobalContext(), bindings, renderConfig.getDisabled()); } - JinjavaInterpreter interpreter = new JinjavaInterpreter(this, context, renderConfig); - JinjavaInterpreter.pushCurrent(interpreter); - try { - String result = interpreter.render(template); - return new RenderResult(result, interpreter.getContext(), interpreter.getErrors()); - } catch (InterpretException e) { - return new RenderResult(TemplateError.fromSyntaxError(e), interpreter.getContext(), interpreter.getErrors()); - } catch (Exception e) { - return new RenderResult(TemplateError.fromException(e), interpreter.getContext(), interpreter.getErrors()); + JinjavaInterpreter interpreter = globalConfig + .getInterpreterFactory() + .newInstance(this, context, renderConfig); + try { + String result = stripTrailingNewlineIfNeeded(interpreter.render(template)); + return new RenderResult( + result, + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } catch (InterpretException e) { + if (e instanceof TemplateSyntaxException) { + return new RenderResult( + TemplateError.fromException((TemplateSyntaxException) e), + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } + return new RenderResult( + TemplateError.fromSyntaxError(e), + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } catch (InvalidArgumentException e) { + return new RenderResult( + TemplateError.fromInvalidArgumentException(e), + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } catch (InvalidInputException e) { + return new RenderResult( + TemplateError.fromInvalidInputException(e), + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } catch (Exception e) { + return new RenderResult( + TemplateError.fromException(e), + interpreter.getContext(), + interpreter.getErrorsCopy() + ); + } } finally { - JinjavaInterpreter.popCurrent(); + globalContext.reset(); } } + /** + * Strips a single trailing newline from the rendered output when + * {@code keepTrailingNewline} is {@code false} in {@link Config}, + * matching Python Jinja2's default behaviour. + */ + private String stripTrailingNewlineIfNeeded(String output) { + if (!globalConfig.isKeepTrailingNewline() && output.endsWith("\n")) { + return output.substring(0, output.length() - 1); + } + return output; + } + /** * Creates a new interpreter instance using the global context and global config * * @return a new interpreter instance */ public JinjavaInterpreter newInterpreter() { - return new JinjavaInterpreter(this, this.getGlobalContext(), this.getGlobalConfig()); + return globalConfig + .getInterpreterFactory() + .newInstance(this, copyGlobalContext(), this.getGlobalConfig()); + } + + public void registerTag(Tag t) { + globalContext.registerTag(t); } + public void registerFunction(ELFunctionDefinition f) { + globalContext.registerFunction(f); + } + + public void registerFilter(Filter f) { + globalContext.registerFilter(f); + } + + public void registerExpTest(ExpTest t) { + globalContext.registerExpTest(t); + } + + protected Map createBindingsWithParentContext( + Map bindings, + Map bindingsFromParentContext + ) { + Map bindingsWithParentContext = new HashMap<>(bindings); + if (bindingsFromParentContext != null) { + bindingsWithParentContext.putAll(bindingsFromParentContext); + } + return bindingsWithParentContext; + } + + private Context copyGlobalContext() { + Context context = new Context(null, globalContext); + // copy registered. + globalContext.getAllExpTests().forEach(context::registerExpTest); + globalContext.getAllFilters().forEach(context::registerFilter); + globalContext.getAllFunctions().forEach(context::registerFunction); + globalContext.getAllTags().forEach(context::registerTag); + context.setAutoEscape(globalContext.isAutoEscape()); + context.setDynamicVariableResolver(globalContext.getDynamicVariableResolver()); + return context; + } } diff --git a/src/main/java/com/hubspot/jinjava/JinjavaConfig.java b/src/main/java/com/hubspot/jinjava/JinjavaConfig.java index a6d96a9ea..322f460c6 100644 --- a/src/main/java/com/hubspot/jinjava/JinjavaConfig.java +++ b/src/main/java/com/hubspot/jinjava/JinjavaConfig.java @@ -15,116 +15,270 @@ **********************************************************************/ package com.hubspot.jinjava; +import static com.hubspot.jinjava.lib.fn.Functions.DEFAULT_RANGE_LIMIT; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.el.JinjavaInterpreterResolver; +import com.hubspot.jinjava.el.JinjavaObjectUnwrapper; +import com.hubspot.jinjava.el.JinjavaProcessors; +import com.hubspot.jinjava.el.ObjectUnwrapper; +import com.hubspot.jinjava.el.ext.AllowlistMethodValidator; +import com.hubspot.jinjava.el.ext.AllowlistReturnTypeValidator; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.Features; +import com.hubspot.jinjava.interpret.Context.Library; +import com.hubspot.jinjava.interpret.InterpreterFactory; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreterFactory; +import com.hubspot.jinjava.mode.DefaultExecutionMode; +import com.hubspot.jinjava.mode.ExecutionMode; +import com.hubspot.jinjava.objects.date.CurrentDateTimeProvider; +import com.hubspot.jinjava.objects.date.DateTimeProvider; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import javax.el.ELResolver; +import org.immutables.value.Value; +@Value.Immutable(singleton = true) +@JinjavaImmutableStyle.WithStyle +@Value.Style( + init = "with*", + get = { "is*", "get*" }, // Detect 'get' and 'is' prefixes in accessor methods + build = "buildImpl", // This is an alias for keeping binary compatibility on the "build" method. + visibility = Value.Style.ImplementationVisibility.PACKAGE +) public class JinjavaConfig { - private final Charset charset; - private final Locale locale; - private final ZoneId timeZone; - private final int maxRenderDepth; + public JinjavaConfig() {} - private final boolean trimBlocks; - private final boolean lstripBlocks; + @Value.Default + public Charset getCharset() { + return StandardCharsets.UTF_8; + } - public static Builder newBuilder() { - return new Builder(); + @Value.Default + public Locale getLocale() { + return Locale.ENGLISH; } - public JinjavaConfig() { - this(StandardCharsets.UTF_8, Locale.ENGLISH, ZoneOffset.UTC, 10, false, false); + @Value.Default + public ZoneId getTimeZone() { + return ZoneOffset.UTC; } - public JinjavaConfig(Charset charset, Locale locale, ZoneId timeZone, int maxRenderDepth) { - this(charset, locale, timeZone, maxRenderDepth, false, false); + @Value.Default + public int getMaxRenderDepth() { + return 10; } - private JinjavaConfig(Charset charset, - Locale locale, - ZoneId timeZone, - int maxRenderDepth, - boolean trimBlocks, - boolean lstripBlocks) { - this.charset = charset; - this.locale = locale; - this.timeZone = timeZone; - this.maxRenderDepth = maxRenderDepth; - this.trimBlocks = trimBlocks; - this.lstripBlocks = lstripBlocks; + @Value.Default + public long getMaxOutputSize() { + return 0; } - public Charset getCharset() { - return charset; + @Value.Default + public boolean isTrimBlocks() { + return false; } - public Locale getLocale() { - return locale; + @Value.Default + public boolean isLstripBlocks() { + return false; } - public ZoneId getTimeZone() { - return timeZone; + @Value.Default + public boolean isEnableRecursiveMacroCalls() { + return false; } - public int getMaxRenderDepth() { - return maxRenderDepth; + @Value.Default + public int getMaxMacroRecursionDepth() { + return 0; } - public boolean isTrimBlocks() { - return trimBlocks; + @Value.Default + public Map> getDisabled() { + return ImmutableMap.of(); } - public boolean isLstripBlocks() { - return lstripBlocks; + @Value.Default + public boolean isFailOnUnknownTokens() { + return false; + } + + @Value.Default + public boolean isNestedInterpretationEnabled() { + return false; // Default changed to false in 3.0 } - public static class Builder { - private Charset charset = StandardCharsets.UTF_8; - private Locale locale = Locale.ENGLISH; - private ZoneId timeZone = ZoneOffset.UTC; - private int maxRenderDepth = 10; + @Value.Default + public RandomNumberGeneratorStrategy getRandomNumberGeneratorStrategy() { + return RandomNumberGeneratorStrategy.THREAD_LOCAL; + } - private boolean trimBlocks; - private boolean lstripBlocks; + @Value.Default + public boolean isValidationMode() { + return false; + } - private Builder() {} + @Value.Default + public long getMaxStringLength() { + return getMaxOutputSize(); + } - public Builder withCharset(Charset charset) { - this.charset = charset; - return this; - } + @Value.Default + public int getMaxListSize() { + return Integer.MAX_VALUE; + } - public Builder withLocale(Locale locale) { - this.locale = locale; - return this; - } + @Value.Default + public int getMaxMapSize() { + return Integer.MAX_VALUE; + } - public Builder withTimeZone(ZoneId timeZone) { - this.timeZone = timeZone; - return this; - } + @Value.Default + public int getRangeLimit() { + return DEFAULT_RANGE_LIMIT; + } - public Builder withMaxRenderDepth(int maxRenderDepth) { - this.maxRenderDepth = maxRenderDepth; - return this; - } + @Value.Default + public int getMaxNumDeferredTokens() { + return 1000; + } - public Builder withTrimBlocks(boolean trimBlocks) { - this.trimBlocks = trimBlocks; - return this; - } + @Value.Default + public InterpreterFactory getInterpreterFactory() { + return new JinjavaInterpreterFactory(); + } + + @Value.Default + public DateTimeProvider getDateTimeProvider() { + return new CurrentDateTimeProvider(); + } + + @Value.Default + public TokenScannerSymbols getTokenScannerSymbols() { + return new DefaultTokenScannerSymbols(); + } + + @Value.Default + public AllowlistMethodValidator getMethodValidator() { + return AllowlistMethodValidator.DEFAULT; + } + + @Value.Default + public AllowlistReturnTypeValidator getReturnTypeValidator() { + return AllowlistReturnTypeValidator.DEFAULT; + } + + @Value.Default + public ELResolver getElResolver() { + return isDefaultReadOnlyResolver() + ? JinjavaInterpreterResolver.DEFAULT_RESOLVER_READ_ONLY + : JinjavaInterpreterResolver.DEFAULT_RESOLVER_READ_WRITE; + } + + @Value.Default + public boolean isDefaultReadOnlyResolver() { + return true; + } + + @Value.Default + public ExecutionMode getExecutionMode() { + return DefaultExecutionMode.instance(); + } - public Builder withLstripBlocks(boolean lstripBlocks) { - this.lstripBlocks = lstripBlocks; - return this; + @Value.Default + public LegacyOverrides getLegacyOverrides() { + return LegacyOverrides.THREE_POINT_0; + } + + @Value.Default + public boolean getEnablePreciseDivideFilter() { + return false; + } + + @Value.Default + public boolean isEnableFilterChainOptimization() { + return false; + } + + /** + * When {@code false}, a single trailing newline is stripped from the rendered output, + * matching Python Jinja2's default. + * When {@code true}, the trailing newline of the rendered output is preserved — + * matching Jinjava's historical behaviour. + * Defaults to {@link LegacyOverrides#getDefaultKeepTrailingNewlineBehavior()}. + */ + @Value.Default + public boolean isKeepTrailingNewline() { + return getLegacyOverrides().getDefaultKeepTrailingNewlineBehavior(); + } + + @Value.Default + public ObjectMapper getObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper().registerModule(new Jdk8Module()); + if (getLegacyOverrides().isUseSnakeCasePropertyNaming()) { + objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); } + return objectMapper; + } + + @Value.Default + public ObjectUnwrapper getObjectUnwrapper() { + return new JinjavaObjectUnwrapper(); + } + + @Value.Derived + public Features getFeatures() { + return new Features(getFeatureConfig()); + } + + @Value.Default + public FeatureConfig getFeatureConfig() { + return FeatureConfig.newBuilder().build(); + } + + @Value.Default + public JinjavaProcessors getProcessors() { + return JinjavaProcessors.newBuilder().build(); + } + + @Deprecated + public BiConsumer getNodePreProcessor() { + return getProcessors().getNodePreProcessor(); + } + + @Deprecated + public boolean isIterateOverMapKeys() { + return getLegacyOverrides().isIterateOverMapKeys(); + } + + public static class Builder extends ImmutableJinjavaConfig.Builder { public JinjavaConfig build() { - return new JinjavaConfig(charset, locale, timeZone, maxRenderDepth, trimBlocks, lstripBlocks); + return super.buildImpl(); } } + public static Builder builder() { + return new Builder(); + } + + public static Builder newBuilder() { + return builder(); + } } diff --git a/src/main/java/com/hubspot/jinjava/JinjavaImmutableStyle.java b/src/main/java/com/hubspot/jinjava/JinjavaImmutableStyle.java new file mode 100644 index 000000000..4a38d3789 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/JinjavaImmutableStyle.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava; + +import org.immutables.value.Value; + +@Value.Style( + init = "set*", + get = { "is*", "get*" } // Detect 'get' and 'is' prefixes in accessor methods +) +public @interface JinjavaImmutableStyle { + @Value.Style( + init = "with*", + get = { "is*", "get*" } // Detect 'get' and 'is' prefixes in accessor methods + ) + @interface WithStyle { + } +} diff --git a/src/main/java/com/hubspot/jinjava/LegacyOverrides.java b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java new file mode 100644 index 000000000..ce4fb9626 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java @@ -0,0 +1,235 @@ +package com.hubspot.jinjava; + +/** + * This class allows Jinjava to be configured to override legacy behaviour. + * LegacyOverrides.NONE signifies that none of the legacy functionality will be overridden. + * LegacyOverrides.ALL signifies that all new functionality will be used; avoid legacy "bugs". + */ +public class LegacyOverrides { + + public static final LegacyOverrides NONE = new LegacyOverrides.Builder().build(); + public static final LegacyOverrides THREE_POINT_0 = new Builder() + .withEvaluateMapKeys(true) + .withIterateOverMapKeys(true) + .withUsePyishObjectMapper(true) + .withUseSnakeCasePropertyNaming(true) + .withUseNaturalOperatorPrecedence(true) + .withParseWhitespaceControlStrictly(true) + .withAllowAdjacentTextNodes(true) + .withUseTrimmingForNotesAndExpressions(true) + .withKeepNullableLoopValues(true) + .withIteratorOnlyReverseFilter(true) + .withHandleBackslashInQuotesOnly(true) + .withDefaultKeepTrailingNewlineBehavior(false) + .build(); + public static final LegacyOverrides ALL = new LegacyOverrides.Builder() + .withEvaluateMapKeys(true) + .withIterateOverMapKeys(true) + .withUsePyishObjectMapper(true) + .withUseSnakeCasePropertyNaming(true) + .withUseNaturalOperatorPrecedence(true) + .withParseWhitespaceControlStrictly(true) + .withAllowAdjacentTextNodes(true) + .withUseTrimmingForNotesAndExpressions(true) + .withKeepNullableLoopValues(true) + .withIteratorOnlyReverseFilter(true) + .withHandleBackslashInQuotesOnly(true) + .withDefaultKeepTrailingNewlineBehavior(false) + .build(); + private final boolean evaluateMapKeys; + private final boolean iterateOverMapKeys; + private final boolean usePyishObjectMapper; + private final boolean useSnakeCasePropertyNaming; + private final boolean useNaturalOperatorPrecedence; + private final boolean parseWhitespaceControlStrictly; + private final boolean allowAdjacentTextNodes; + private final boolean useTrimmingForNotesAndExpressions; + private final boolean keepNullableLoopValues; + private final boolean iteratorOnlyReverseFilter; + private final boolean handleBackslashInQuotesOnly; + private final boolean defaultKeepTrailingNewlineBehavior; + + private LegacyOverrides(Builder builder) { + evaluateMapKeys = builder.evaluateMapKeys; + iterateOverMapKeys = builder.iterateOverMapKeys; + usePyishObjectMapper = builder.usePyishObjectMapper; + useSnakeCasePropertyNaming = builder.useSnakeCasePropertyNaming; + useNaturalOperatorPrecedence = builder.useNaturalOperatorPrecedence; + parseWhitespaceControlStrictly = builder.parseWhitespaceControlStrictly; + allowAdjacentTextNodes = builder.allowAdjacentTextNodes; + useTrimmingForNotesAndExpressions = builder.useTrimmingForNotesAndExpressions; + keepNullableLoopValues = builder.keepNullableLoopValues; + iteratorOnlyReverseFilter = builder.iteratorOnlyReverseFilter; + handleBackslashInQuotesOnly = builder.handleBackslashInQuotesOnly; + defaultKeepTrailingNewlineBehavior = builder.defaultKeepTrailingNewlineBehavior; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public boolean isEvaluateMapKeys() { + return evaluateMapKeys; + } + + public boolean isIterateOverMapKeys() { + return iterateOverMapKeys; + } + + public boolean isUsePyishObjectMapper() { + return usePyishObjectMapper; + } + + public boolean isUseSnakeCasePropertyNaming() { + return useSnakeCasePropertyNaming; + } + + public boolean isUseNaturalOperatorPrecedence() { + return useNaturalOperatorPrecedence; + } + + public boolean isParseWhitespaceControlStrictly() { + return parseWhitespaceControlStrictly; + } + + public boolean isAllowAdjacentTextNodes() { + return allowAdjacentTextNodes; + } + + public boolean isUseTrimmingForNotesAndExpressions() { + return useTrimmingForNotesAndExpressions; + } + + public boolean isKeepNullableLoopValues() { + return keepNullableLoopValues; + } + + public boolean isIteratorOnlyReverseFilter() { + return iteratorOnlyReverseFilter; + } + + public boolean isHandleBackslashInQuotesOnly() { + return handleBackslashInQuotesOnly; + } + + /** + * The default value of {@link com.hubspot.jinjava.JinjavaConfig#isKeepTrailingNewline()}. + * {@code true} preserves Jinjava's historical behaviour of keeping the trailing newline; + * {@code false} matches Python Jinja2's default of stripping it. + */ + public boolean getDefaultKeepTrailingNewlineBehavior() { + return defaultKeepTrailingNewlineBehavior; + } + + public static class Builder { + + private boolean evaluateMapKeys = false; + private boolean iterateOverMapKeys = false; + private boolean usePyishObjectMapper = false; + private boolean useSnakeCasePropertyNaming = false; + private boolean useNaturalOperatorPrecedence = false; + private boolean parseWhitespaceControlStrictly = false; + private boolean allowAdjacentTextNodes = false; + private boolean useTrimmingForNotesAndExpressions = false; + private boolean keepNullableLoopValues = false; + private boolean iteratorOnlyReverseFilter = false; + private boolean handleBackslashInQuotesOnly = false; + private boolean defaultKeepTrailingNewlineBehavior = true; + + private Builder() {} + + public LegacyOverrides build() { + return new LegacyOverrides(this); + } + + public static Builder from(LegacyOverrides legacyOverrides) { + return new Builder() + .withEvaluateMapKeys(legacyOverrides.evaluateMapKeys) + .withIterateOverMapKeys(legacyOverrides.iterateOverMapKeys) + .withUsePyishObjectMapper(legacyOverrides.usePyishObjectMapper) + .withUseSnakeCasePropertyNaming(legacyOverrides.useSnakeCasePropertyNaming) + .withUseNaturalOperatorPrecedence(legacyOverrides.useNaturalOperatorPrecedence) + .withParseWhitespaceControlStrictly( + legacyOverrides.parseWhitespaceControlStrictly + ) + .withAllowAdjacentTextNodes(legacyOverrides.allowAdjacentTextNodes) + .withUseTrimmingForNotesAndExpressions( + legacyOverrides.useTrimmingForNotesAndExpressions + ) + .withKeepNullableLoopValues(legacyOverrides.keepNullableLoopValues) + .withIteratorOnlyReverseFilter(legacyOverrides.iteratorOnlyReverseFilter) + .withHandleBackslashInQuotesOnly(legacyOverrides.handleBackslashInQuotesOnly) + .withDefaultKeepTrailingNewlineBehavior( + legacyOverrides.defaultKeepTrailingNewlineBehavior + ); + } + + public Builder withEvaluateMapKeys(boolean evaluateMapKeys) { + this.evaluateMapKeys = evaluateMapKeys; + return this; + } + + public Builder withIterateOverMapKeys(boolean iterateOverMapKeys) { + this.iterateOverMapKeys = iterateOverMapKeys; + return this; + } + + public Builder withUsePyishObjectMapper(boolean usePyishObjectMapper) { + this.usePyishObjectMapper = usePyishObjectMapper; + return this; + } + + public Builder withUseSnakeCasePropertyNaming(boolean useSnakeCasePropertyNaming) { + this.useSnakeCasePropertyNaming = useSnakeCasePropertyNaming; + return this; + } + + public Builder withUseNaturalOperatorPrecedence( + boolean useNaturalOperatorPrecedence + ) { + this.useNaturalOperatorPrecedence = useNaturalOperatorPrecedence; + return this; + } + + public Builder withParseWhitespaceControlStrictly( + boolean parseWhitespaceControlStrictly + ) { + this.parseWhitespaceControlStrictly = parseWhitespaceControlStrictly; + return this; + } + + public Builder withAllowAdjacentTextNodes(boolean allowAdjacentTextNodes) { + this.allowAdjacentTextNodes = allowAdjacentTextNodes; + return this; + } + + public Builder withUseTrimmingForNotesAndExpressions( + boolean useTrimmingForNotesAndExpressions + ) { + this.useTrimmingForNotesAndExpressions = useTrimmingForNotesAndExpressions; + return this; + } + + public Builder withKeepNullableLoopValues(boolean keepNullableLoopValues) { + this.keepNullableLoopValues = keepNullableLoopValues; + return this; + } + + public Builder withIteratorOnlyReverseFilter(boolean iteratorOnlyReverseFilter) { + this.iteratorOnlyReverseFilter = iteratorOnlyReverseFilter; + return this; + } + + public Builder withHandleBackslashInQuotesOnly(boolean handleBackslashInQuotesOnly) { + this.handleBackslashInQuotesOnly = handleBackslashInQuotesOnly; + return this; + } + + public Builder withDefaultKeepTrailingNewlineBehavior( + boolean defaultKeepTrailingNewlineBehavior + ) { + this.defaultKeepTrailingNewlineBehavior = defaultKeepTrailingNewlineBehavior; + return this; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDoc.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDoc.java index 7e3e6bcdd..2b03a8728 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDoc.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDoc.java @@ -9,6 +9,7 @@ public class JinjavaDoc { private final Map filters = new TreeMap<>(); private final Map functions = new TreeMap<>(); private final Map tags = new TreeMap<>(); + private final Map codeSnippets = new TreeMap<>(); public Map getExpTests() { return expTests; @@ -42,4 +43,11 @@ public void addTag(JinjavaDocTag tag) { tags.put(tag.getName(), tag); } + public Map getCodeSnippets() { + return codeSnippets; + } + + public void addCodeSnippet(String name, String snippet) { + codeSnippets.put(name, snippet); + } } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocExpTest.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocExpTest.java index f0fad9f89..d505cd32f 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocExpTest.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocExpTest.java @@ -4,8 +4,16 @@ public class JinjavaDocExpTest extends JinjavaDocItem { - public JinjavaDocExpTest(String name, String desc, String aliasOf, boolean deprecated, JinjavaDocParam[] params, JinjavaDocSnippet[] snippets, Map meta) { - super(name, desc, aliasOf, deprecated, params, snippets, meta); + public JinjavaDocExpTest( + String name, + String desc, + String aliasOf, + boolean deprecated, + JinjavaDocParam[] inputs, + JinjavaDocParam[] params, + JinjavaDocSnippet[] snippets, + Map meta + ) { + super(name, desc, aliasOf, deprecated, inputs, params, snippets, meta); } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFactory.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFactory.java index 7d4d14357..fcb7b14cc 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFactory.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFactory.java @@ -1,29 +1,32 @@ package com.hubspot.jinjava.doc; -import java.lang.reflect.Method; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Throwables; import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaMetaValue; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.lib.exptest.ExpTest; import com.hubspot.jinjava.lib.filter.Filter; import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; import com.hubspot.jinjava.lib.fn.InjectedContextFunctionProxy; import com.hubspot.jinjava.lib.tag.EndTag; import com.hubspot.jinjava.lib.tag.Tag; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class JinjavaDocFactory { + private static final Logger LOG = LoggerFactory.getLogger(JinjavaDocFactory.class); + private static final Class JINJAVA_DOC_CLASS = + com.hubspot.jinjava.doc.annotations.JinjavaDoc.class; + private final Jinjava jinjava; public JinjavaDocFactory(Jinjava jinjava) { @@ -37,80 +40,234 @@ public JinjavaDoc get() { addFilterDocs(doc); addFnDocs(doc); addTagDocs(doc); + addCodeSnippets(doc); return doc; } - private void addExpTests(JinjavaDoc doc) { - for (ExpTest t : jinjava.getGlobalContext().getAllExpTests()) { - com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = t.getClass().getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); + public String getCodeEditorTagSnippets() { + StringBuffer snippets = new StringBuffer(); + for (Tag tag : jinjava.getGlobalContextCopy().getAllTags()) { + if (tag instanceof EndTag) { + continue; + } + snippets.append(getTagSnippet(tag)); + snippets.append("\n\n"); + } + return snippets.toString(); + } + + private void addCodeSnippets(JinjavaDoc doc) { + for (Tag tag : jinjava.getGlobalContextCopy().getAllTags()) { + if (tag instanceof EndTag) { + continue; + } + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + getJinjavaDocAnnotation(tag.getClass()); if (docAnnotation == null) { - LOG.warn("Expression Test {} doesn't have a @{} annotation", t.getName(), com.hubspot.jinjava.doc.annotations.JinjavaDoc.class.getName()); - doc.addExpTest(new JinjavaDocExpTest(t.getName(), "", "", false, new JinjavaDocParam[] {}, new JinjavaDocSnippet[] {}, Collections.emptyMap())); + LOG.warn( + "Tag {} doesn't have a @{} annotation", + tag.getName(), + JINJAVA_DOC_CLASS.getName() + ); + doc.addTag( + new JinjavaDocTag( + tag.getName(), + StringUtils.isBlank(tag.getEndTagName()), + "", + "", + false, + new JinjavaDocParam[] {}, + new JinjavaDocParam[] {}, + new JinjavaDocSnippet[] {}, + Collections.emptyMap() + ) + ); + } else if (!docAnnotation.hidden()) { + doc.addCodeSnippet(tag.getName(), getTagSnippet(tag)); } - else if (!docAnnotation.hidden()) { - doc.addExpTest(new JinjavaDocExpTest(t.getName(), docAnnotation.value(), docAnnotation.aliasOf(), docAnnotation.deprecated(), - extractParams(docAnnotation.params()), extractSnippets(docAnnotation.snippets()), extractMeta(docAnnotation.meta()))); + } + } + + private void addExpTests(JinjavaDoc doc) { + for (ExpTest t : jinjava.getGlobalContextCopy().getAllExpTests()) { + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + getJinjavaDocAnnotation(t.getClass()); + + if (docAnnotation == null) { + LOG.warn( + "Expression Test {} doesn't have a @{} annotation", + t.getName(), + JINJAVA_DOC_CLASS.getName() + ); + doc.addExpTest( + new JinjavaDocExpTest( + t.getName(), + "", + "", + false, + new JinjavaDocParam[] {}, + new JinjavaDocParam[] {}, + new JinjavaDocSnippet[] {}, + Collections.emptyMap() + ) + ); + } else if (!docAnnotation.hidden()) { + doc.addExpTest( + new JinjavaDocExpTest( + t.getName(), + docAnnotation.value(), + docAnnotation.aliasOf(), + docAnnotation.deprecated(), + extractParams(docAnnotation.input()), + extractParams(docAnnotation.params()), + extractSnippets(docAnnotation.snippets()), + extractMeta(docAnnotation.meta()) + ) + ); } } } private void addFilterDocs(JinjavaDoc doc) { - for (Filter f : jinjava.getGlobalContext().getAllFilters()) { - com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = f.getClass().getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); + for (Filter f : jinjava.getGlobalContextCopy().getAllFilters()) { + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + getJinjavaDocAnnotation(f.getClass()); if (docAnnotation == null) { - LOG.warn("Filter {} doesn't have a @{} annotation", f.getClass(), com.hubspot.jinjava.doc.annotations.JinjavaDoc.class.getName()); - doc.addFilter(new JinjavaDocFilter(f.getName(), "", "", false, new JinjavaDocParam[] {}, new JinjavaDocSnippet[] {}, Collections.emptyMap())); - } - else if (!docAnnotation.hidden()) { - doc.addFilter(new JinjavaDocFilter(f.getName(), docAnnotation.value(), docAnnotation.aliasOf(), docAnnotation.deprecated(), - extractParams(docAnnotation.params()), extractSnippets(docAnnotation.snippets()), extractMeta(docAnnotation.meta()))); + LOG.warn( + "Filter {} doesn't have a @{} annotation", + f.getClass(), + JINJAVA_DOC_CLASS.getName() + ); + doc.addFilter( + new JinjavaDocFilter( + f.getName(), + "", + "", + false, + new JinjavaDocParam[] {}, + new JinjavaDocParam[] {}, + new JinjavaDocSnippet[] {}, + Collections.emptyMap() + ) + ); + } else if (!docAnnotation.hidden()) { + doc.addFilter( + new JinjavaDocFilter( + f.getName(), + docAnnotation.value(), + docAnnotation.aliasOf(), + docAnnotation.deprecated(), + extractParams(docAnnotation.input()), + extractParams(docAnnotation.params()), + extractSnippets(docAnnotation.snippets()), + extractMeta(docAnnotation.meta()) + ) + ); } } } private void addFnDocs(JinjavaDoc doc) { - for (ELFunctionDefinition fn : jinjava.getGlobalContext().getAllFunctions()) { + for (ELFunctionDefinition fn : jinjava.getGlobalContextCopy().getAllFunctions()) { if (StringUtils.isBlank(fn.getNamespace())) { Method realMethod = fn.getMethod(); - if (realMethod.getDeclaringClass().getName().contains(InjectedContextFunctionProxy.class.getSimpleName())) { + if ( + realMethod + .getDeclaringClass() + .getName() + .contains(InjectedContextFunctionProxy.class.getSimpleName()) + ) { try { - realMethod = (Method) realMethod.getDeclaringClass().getField("delegate").get(null); + realMethod = + (Method) realMethod.getDeclaringClass().getField("delegate").get(null); } catch (Exception e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = realMethod.getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + realMethod.getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); if (docAnnotation == null) { - LOG.warn("Function {} doesn't have a @{} annotation", fn.getName(), com.hubspot.jinjava.doc.annotations.JinjavaDoc.class.getName()); - doc.addFunction(new JinjavaDocFunction(fn.getLocalName(), "", "", false, new JinjavaDocParam[] {}, new JinjavaDocSnippet[] {}, Collections.emptyMap())); - } - else if (!docAnnotation.hidden()) { - doc.addFunction(new JinjavaDocFunction(fn.getLocalName(), docAnnotation.value(), docAnnotation.aliasOf(), docAnnotation.deprecated(), - extractParams(docAnnotation.params()), extractSnippets(docAnnotation.snippets()), extractMeta(docAnnotation.meta()))); + LOG.warn( + "Function {} doesn't have a @{} annotation", + fn.getName(), + JINJAVA_DOC_CLASS.getName() + ); + doc.addFunction( + new JinjavaDocFunction( + fn.getLocalName(), + "", + "", + false, + new JinjavaDocParam[] {}, + new JinjavaDocParam[] {}, + new JinjavaDocSnippet[] {}, + Collections.emptyMap() + ) + ); + } else if (!docAnnotation.hidden()) { + doc.addFunction( + new JinjavaDocFunction( + fn.getLocalName(), + docAnnotation.value(), + docAnnotation.aliasOf(), + docAnnotation.deprecated(), + extractParams(docAnnotation.input()), + extractParams(docAnnotation.params()), + extractSnippets(docAnnotation.snippets()), + extractMeta(docAnnotation.meta()) + ) + ); } } } } private void addTagDocs(JinjavaDoc doc) { - for (Tag t : jinjava.getGlobalContext().getAllTags()) { + for (Tag t : jinjava.getGlobalContextCopy().getAllTags()) { if (t instanceof EndTag) { continue; } - com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = t.getClass().getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + getJinjavaDocAnnotation(t.getClass()); if (docAnnotation == null) { - LOG.warn("Tag {} doesn't have a @{} annotation", t.getName(), com.hubspot.jinjava.doc.annotations.JinjavaDoc.class.getName()); - doc.addTag(new JinjavaDocTag(t.getName(), StringUtils.isBlank(t.getEndTagName()), "", "", false, new JinjavaDocParam[] {}, new JinjavaDocSnippet[] {}, Collections.emptyMap())); - } - else if (!docAnnotation.hidden()) { - doc.addTag(new JinjavaDocTag(t.getName(), StringUtils.isBlank(t.getEndTagName()), docAnnotation.value(), docAnnotation.aliasOf(), docAnnotation.deprecated(), - extractParams(docAnnotation.params()), extractSnippets(docAnnotation.snippets()), extractMeta(docAnnotation.meta()))); + LOG.warn( + "Tag {} doesn't have a @{} annotation", + t.getName(), + JINJAVA_DOC_CLASS.getName() + ); + doc.addTag( + new JinjavaDocTag( + t.getName(), + StringUtils.isBlank(t.getEndTagName()), + "", + "", + false, + new JinjavaDocParam[] {}, + new JinjavaDocParam[] {}, + new JinjavaDocSnippet[] {}, + Collections.emptyMap() + ) + ); + } else if (!docAnnotation.hidden()) { + doc.addTag( + new JinjavaDocTag( + t.getName(), + StringUtils.isBlank(t.getEndTagName()), + docAnnotation.value(), + docAnnotation.aliasOf(), + docAnnotation.deprecated(), + extractParams(docAnnotation.input()), + extractParams(docAnnotation.params()), + extractSnippets(docAnnotation.snippets()), + extractMeta(docAnnotation.meta()) + ) + ); } } } @@ -120,7 +277,14 @@ private JinjavaDocParam[] extractParams(JinjavaParam[] params) { for (int i = 0; i < params.length; i++) { JinjavaParam p = params[i]; - result[i] = new JinjavaDocParam(p.value(), p.type(), p.desc(), p.defaultValue()); + result[i] = + new JinjavaDocParam( + p.value(), + p.type(), + p.desc(), + p.defaultValue(), + p.required() + ); } return result; @@ -147,4 +311,59 @@ private Map extractMeta(JinjavaMetaValue[] metaValues) { return meta; } + private com.hubspot.jinjava.doc.annotations.JinjavaDoc getJinjavaDocAnnotation( + Class clazz + ) { + clazz = InjectedContextFunctionProxy.removeGuiceWrapping(clazz); + + return clazz.getAnnotation(com.hubspot.jinjava.doc.annotations.JinjavaDoc.class); + } + + private String getTagSnippet(Tag tag) { + JinjavaTextMateSnippet annotation = tag + .getClass() + .getAnnotation(JinjavaTextMateSnippet.class); + if (annotation != null) { + return annotation.code(); + } + com.hubspot.jinjava.doc.annotations.JinjavaDoc docAnnotation = + getJinjavaDocAnnotation(tag.getClass()); + StringBuilder snippet = new StringBuilder("{% "); + snippet.append(tag.getName()); + int i = 1; + if (docAnnotation != null) { + for (JinjavaParam param : docAnnotation.input()) { + String inputValue = "${" + i + ":" + param.value() + "}"; + if (param.value().equalsIgnoreCase("path")) { + inputValue = "'" + inputValue + "'"; + } else if (param.value().equalsIgnoreCase("argument_names")) { + inputValue = "(" + inputValue + ")"; + } + snippet.append(" " + inputValue); + i++; + } + + for (JinjavaParam param : docAnnotation.params()) { + String paramValue = param.value() + "=\"${" + i + ":" + param.value() + "}\""; + if (param.value().equalsIgnoreCase("path")) { + paramValue = "'" + paramValue + "'"; + } else if (param.value().equalsIgnoreCase("argument_names")) { + paramValue = "(" + paramValue + ")"; + } + snippet.append(" " + paramValue); + i++; + } + } + + snippet.append(" %}"); + + if (tag.getClass().getAnnotation(JinjavaHasCodeBody.class) != null) { + snippet.append("\n$0"); + } + if (tag.getEndTagName() != null) { + snippet.append("\n{% " + tag.getEndTagName() + " %}"); + } + + return snippet.toString(); + } } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFilter.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFilter.java index a4d94753e..a0f42df20 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFilter.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFilter.java @@ -4,8 +4,16 @@ public class JinjavaDocFilter extends JinjavaDocItem { - public JinjavaDocFilter(String name, String desc, String aliasOf, boolean deprecated, JinjavaDocParam[] params, JinjavaDocSnippet[] snippets, Map meta) { - super(name, desc, aliasOf, deprecated, params, snippets, meta); + public JinjavaDocFilter( + String name, + String desc, + String aliasOf, + boolean deprecated, + JinjavaDocParam[] inputs, + JinjavaDocParam[] params, + JinjavaDocSnippet[] snippets, + Map meta + ) { + super(name, desc, aliasOf, deprecated, inputs, params, snippets, meta); } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFunction.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFunction.java index b053fb756..b74d73c4d 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFunction.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocFunction.java @@ -4,8 +4,16 @@ public class JinjavaDocFunction extends JinjavaDocItem { - public JinjavaDocFunction(String name, String desc, String aliasOf, boolean deprecated, JinjavaDocParam[] params, JinjavaDocSnippet[] snippets, Map meta) { - super(name, desc, aliasOf, deprecated, params, snippets, meta); + public JinjavaDocFunction( + String name, + String desc, + String aliasOf, + boolean deprecated, + JinjavaDocParam[] inputs, + JinjavaDocParam[] params, + JinjavaDocSnippet[] snippets, + Map meta + ) { + super(name, desc, aliasOf, deprecated, inputs, params, snippets, meta); } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocItem.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocItem.java index 6d5737158..543957ad2 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocItem.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocItem.java @@ -8,15 +8,26 @@ public abstract class JinjavaDocItem { private final String desc; private final String aliasOf; private final boolean deprecated; + private final JinjavaDocParam[] inputs; private final JinjavaDocParam[] params; private final JinjavaDocSnippet[] snippets; private final Map meta; - public JinjavaDocItem(String name, String desc, String aliasOf, boolean deprecated, JinjavaDocParam[] params, JinjavaDocSnippet[] snippets, Map meta) { + public JinjavaDocItem( + String name, + String desc, + String aliasOf, + boolean deprecated, + JinjavaDocParam[] inputs, + JinjavaDocParam[] params, + JinjavaDocSnippet[] snippets, + Map meta + ) { this.name = name; this.desc = desc; this.aliasOf = aliasOf; this.deprecated = deprecated; + this.inputs = inputs.clone(); this.params = params.clone(); this.snippets = snippets.clone(); this.meta = meta; @@ -38,6 +49,10 @@ public boolean isDeprecated() { return deprecated; } + public JinjavaDocParam[] getInputs() { + return inputs.clone(); + } + public JinjavaDocParam[] getParams() { return params.clone(); } @@ -49,5 +64,4 @@ public JinjavaDocSnippet[] getSnippets() { public Map getMeta() { return meta; } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocParam.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocParam.java index ff1c8ca4a..8ff38dacb 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocParam.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocParam.java @@ -6,12 +6,20 @@ public class JinjavaDocParam { private final String type; private final String desc; private final String defaultValue; - - public JinjavaDocParam(String name, String type, String desc, String defaultValue) { + private final boolean required; + + public JinjavaDocParam( + String name, + String type, + String desc, + String defaultValue, + boolean required + ) { this.name = name; this.type = type; this.desc = desc; this.defaultValue = defaultValue; + this.required = required; } public String getName() { @@ -30,4 +38,7 @@ public String getDefaultValue() { return defaultValue; } + public boolean getRequired() { + return required; + } } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocSnippet.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocSnippet.java index b06ad4bbc..78ba4c7f5 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocSnippet.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocSnippet.java @@ -23,5 +23,4 @@ public String getCode() { public String getOutput() { return output; } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocTag.java b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocTag.java index 0e1c02b9a..39ae25d96 100644 --- a/src/main/java/com/hubspot/jinjava/doc/JinjavaDocTag.java +++ b/src/main/java/com/hubspot/jinjava/doc/JinjavaDocTag.java @@ -6,13 +6,22 @@ public class JinjavaDocTag extends JinjavaDocItem { private final boolean empty; - public JinjavaDocTag(String name, boolean empty, String desc, String aliasOf, boolean deprecated, JinjavaDocParam[] params, JinjavaDocSnippet[] snippets, Map meta) { - super(name, desc, aliasOf, deprecated, params, snippets, meta); + public JinjavaDocTag( + String name, + boolean empty, + String desc, + String aliasOf, + boolean deprecated, + JinjavaDocParam[] inputs, + JinjavaDocParam[] params, + JinjavaDocSnippet[] snippets, + Map meta + ) { + super(name, desc, aliasOf, deprecated, inputs, params, snippets, meta); this.empty = empty; } public boolean isEmpty() { return empty; } - } diff --git a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaDoc.java b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaDoc.java index e9b56a78f..36dbd278b 100644 --- a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaDoc.java +++ b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaDoc.java @@ -1,16 +1,19 @@ package com.hubspot.jinjava.doc.annotations; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) +@Inherited public @interface JinjavaDoc { - String value() default ""; + JinjavaParam[] input() default {}; + JinjavaParam[] params() default {}; JinjavaSnippet[] snippets() default {}; @@ -22,5 +25,4 @@ boolean deprecated() default false; boolean hidden() default false; - } diff --git a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaHasCodeBody.java b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaHasCodeBody.java new file mode 100644 index 000000000..1feded364 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaHasCodeBody.java @@ -0,0 +1,11 @@ +package com.hubspot.jinjava.doc.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface JinjavaHasCodeBody { +} diff --git a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaParam.java b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaParam.java index c8f4e4448..928cc68dd 100644 --- a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaParam.java +++ b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaParam.java @@ -8,7 +8,6 @@ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) public @interface JinjavaParam { - String value(); String type() default "String"; @@ -17,4 +16,5 @@ String defaultValue() default ""; + boolean required() default false; } diff --git a/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaTextMateSnippet.java b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaTextMateSnippet.java new file mode 100644 index 000000000..0ac28a35a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/doc/annotations/JinjavaTextMateSnippet.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava.doc.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface JinjavaTextMateSnippet { + String desc() default ""; + + String code(); + + String output() default ""; +} diff --git a/src/main/java/com/hubspot/jinjava/el/ExpressionResolver.java b/src/main/java/com/hubspot/jinjava/el/ExpressionResolver.java index bd711bc64..aaab4a4c6 100644 --- a/src/main/java/com/hubspot/jinjava/el/ExpressionResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/ExpressionResolver.java @@ -2,23 +2,37 @@ import static org.apache.commons.lang3.exception.ExceptionUtils.getRootCauseMessage; -import java.util.List; - -import javax.el.ELException; -import javax.el.ExpressionFactory; -import javax.el.PropertyNotFoundException; -import javax.el.ValueExpression; - -import org.apache.commons.lang3.StringUtils; - +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.el.ext.NamedParameter; +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.IndexOutOfRangeException; import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import com.hubspot.jinjava.interpret.TemplateError.ErrorType; import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.interpret.UnknownTokenException; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.util.WhitespaceUtils; import de.odysseus.el.tree.TreeBuilderException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import javax.el.ELException; +import javax.el.ExpressionFactory; +import javax.el.PropertyNotFoundException; +import javax.el.ValueExpression; +import org.apache.commons.lang3.StringUtils; /** * Resolves Jinja expressions. @@ -27,18 +41,30 @@ public class ExpressionResolver { private final JinjavaInterpreter interpreter; private final ExpressionFactory expressionFactory; - private final JinjavaInterpreterResolver resolver; + private final ReturnTypeValidatingJinjavaInterpreterResolver resolver; private final JinjavaELContext elContext; + private final ObjectUnwrapper objectUnwrapper; - public ExpressionResolver(JinjavaInterpreter interpreter, ExpressionFactory expressionFactory) { + private static final String EXPRESSION_START_TOKEN = "#{"; + private static final String EXPRESSION_END_TOKEN = "}"; + + public ExpressionResolver(JinjavaInterpreter interpreter, Jinjava jinjava) { this.interpreter = interpreter; - this.expressionFactory = expressionFactory; + this.expressionFactory = + interpreter.getConfig().getExecutionMode().useEagerParser() + ? jinjava.getEagerExpressionFactory() + : jinjava.getExpressionFactory(); - this.resolver = new JinjavaInterpreterResolver(interpreter); - this.elContext = new JinjavaELContext(resolver); - for (ELFunctionDefinition fn : interpreter.getContext().getAllFunctions()) { + this.resolver = + new ReturnTypeValidatingJinjavaInterpreterResolver( + interpreter.getConfig().getReturnTypeValidator(), + new JinjavaInterpreterResolver(interpreter) + ); + this.elContext = new JinjavaELContext(interpreter, resolver); + for (ELFunctionDefinition fn : jinjava.getGlobalContext().getAllFunctions()) { this.elContext.setFunction(fn.getNamespace(), fn.getLocalName(), fn.getMethod()); } + objectUnwrapper = interpreter.getConfig().getObjectUnwrapper(); } /** @@ -48,34 +74,254 @@ public ExpressionResolver(JinjavaInterpreter interpreter, ExpressionFactory expr * @return Value of expression. */ public Object resolveExpression(String expression) { + return resolveExpression(expression, true); + } + + /** + * Resolve expression against current context without adding the expression to the set of resolved expressions. + * + * @param expression Jinja expression. + * @return Value of expression. + */ + public Object resolveExpressionSilently(String expression) { + return resolveExpression(expression, false); + } + + private Object resolveExpression(String expression, boolean addToResolvedExpressions) { if (StringUtils.isBlank(expression)) { - return ""; + return null; + } + expression = expression.trim(); + if (addToResolvedExpressions) { + if (WhitespaceUtils.isWrappedWith(expression, "[", "]")) { + String commaSeparatedExpress = expression + .substring(1, expression.length() - 1) + .trim(); + // Over-simplified way to detect JSON format, avoid to break it for adding resolved expressions. + if ( + !commaSeparatedExpress.startsWith("{") || !commaSeparatedExpress.endsWith("}") + ) { + Arrays + .stream(commaSeparatedExpress.split(",")) + .forEach(substring -> + interpreter.getContext().addResolvedExpression(substring.trim()) + ); + } + } + interpreter.getContext().addResolvedExpression(expression); } try { - String elExpression = "#{" + expression.trim() + "}"; - ValueExpression valueExp = expressionFactory.createValueExpression(elContext, elExpression, Object.class); - return valueExp.getValue(elContext); + String elExpression = EXPRESSION_START_TOKEN + expression + EXPRESSION_END_TOKEN; + ValueExpression valueExp = expressionFactory.createValueExpression( + elContext, + elExpression, + Object.class + ); + Object result = valueExp.getValue(elContext); + if (result == null && interpreter.getConfig().isFailOnUnknownTokens()) { + throw new UnknownTokenException( + expression, + interpreter.getLineNumber(), + interpreter.getPosition() + ); + } + + result = objectUnwrapper.unwrapObject(result); + + validateResult(result); + return result; } catch (PropertyNotFoundException e) { - interpreter.addError(new TemplateError(ErrorType.WARNING, ErrorReason.UNKNOWN, e.getMessage(), "", interpreter.getLineNumber(), e)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.UNKNOWN, + ErrorItem.PROPERTY, + e.getMessage(), + "", + interpreter.getLineNumber(), + interpreter.getPosition(), + e, + BasicTemplateErrorCategory.UNKNOWN, + ImmutableMap.of("exception", e.getMessage()) + ) + ); } catch (TreeBuilderException e) { - interpreter.addError(TemplateError.fromException(new TemplateSyntaxException(expression, - "Error parsing '" + expression + "': " + StringUtils.substringAfter(e.getMessage(), "': "), interpreter.getLineNumber(), e))); + int position = interpreter.getPosition() + e.getPosition(); + // replacing the position in the string like this isn't great, but JUEL's parser does not allow passing in a starting position + String errorMessage = StringUtils + .substringAfter(e.getMessage(), "': ") + .replaceFirst("position [0-9]+", "position " + position); + interpreter.addError( + TemplateError.fromException( + new TemplateSyntaxException( + expression.substring( + Math.min( + expression.length(), + Math.max(e.getPosition() - EXPRESSION_START_TOKEN.length(), 0) + ) + ), + "Error parsing '" + expression + "': " + errorMessage, + interpreter.getLineNumber(), + position, + e + ) + ) + ); } catch (ELException e) { - interpreter.addError(TemplateError.fromException(new TemplateSyntaxException(expression, e.getMessage(), interpreter.getLineNumber(), e))); + handleELException(expression, e); + } catch (DisabledException e) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.DISABLED, + ErrorItem.FUNCTION, + e.getMessage(), + expression, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + } catch (UnknownTokenException e) { + // Re-throw the exception because you only get this when the config failOnUnknownTokens is enabled. + throw e; + } catch (DeferredValueException e) { + // Re-throw so that it can be handled in JinjavaInterpreter + throw e; + } catch (InvalidInputException e) { + interpreter.addError(TemplateError.fromInvalidInputException(e)); + } catch (InvalidArgumentException e) { + interpreter.addError(TemplateError.fromInvalidArgumentException(e)); + } catch (ArithmeticException e) { + interpreter.addError( + TemplateError.fromInvalidInputException( + new InvalidInputException( + interpreter, + ExpressionResolver.class.getName(), + String.format( + "ArithmeticException when resolving expression [%s]: " + + getRootCauseMessage(e), + expression + ) + ) + ) + ); } catch (Exception e) { - interpreter.addError(TemplateError.fromException(new InterpretException( - String.format("Error resolving expression [%s]: " + getRootCauseMessage(e), expression), e, interpreter.getLineNumber()))); + interpreter.addError( + TemplateError.fromException( + new InterpretException( + String.format( + "Error resolving expression [%s]: " + getRootCauseMessage(e), + expression + ), + e, + interpreter.getLineNumber(), + interpreter.getPosition() + ) + ) + ); } - return ""; + return null; + } + + private void handleELException(String expression, ELException e) { + if (e.getCause() != null && e.getCause() instanceof DeferredValueException) { + throw (DeferredValueException) e.getCause(); + } + if (e.getCause() != null && e.getCause() instanceof TemplateSyntaxException) { + interpreter.addError( + TemplateError.fromException((TemplateSyntaxException) e.getCause()) + ); + } else if (e.getCause() != null && e.getCause() instanceof InvalidInputException) { + interpreter.addError( + TemplateError.fromInvalidInputException((InvalidInputException) e.getCause()) + ); + } else if (e.getCause() != null && e.getCause() instanceof InvalidArgumentException) { + interpreter.addError( + TemplateError.fromInvalidArgumentException( + (InvalidArgumentException) e.getCause() + ) + ); + } else if ( + e.getCause() != null && e.getCause() instanceof CollectionTooBigException + ) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.COLLECTION_TOO_BIG, + e.getCause().getMessage(), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + // rethrow because this is a hard limit and it will likely only happen in loops that we need to terminate + throw e; + } else if (e.getCause() != null && e.getCause() instanceof IndexOutOfRangeException) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.FUNCTION, + e.getMessage(), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + } else if (e.getCause() != null && e.getCause() instanceof OutputTooBigException) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.OUTPUT_TOO_BIG, + ErrorItem.FUNCTION, + e.getCause().getMessage(), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + } else { + String originatingException = getRootCauseMessage(e); + final String combinedMessage = String.format( + "%s%nOriginating Exception:%n%s", + e.getMessage(), + originatingException + ); + interpreter.addError( + TemplateError.fromException( + new TemplateSyntaxException( + expression, + (e.getCause() == null || + StringUtils.endsWith(originatingException, e.getCause().getMessage())) + ? e.getMessage() + : combinedMessage, + interpreter.getLineNumber(), + e + ) + ) + ); + } + } + + private void validateResult(Object result) { + if (result instanceof NamedParameter) { + throw new ELException( + "Unexpected '=' operator (use {% set %} tag for variable assignment)" + ); + } } /** * Resolve property of bean. * - * @param object Bean. + * @param object Bean. * @param propertyNames Names of properties to resolve recursively. * @return Value of property. */ @@ -93,4 +339,21 @@ public Object resolveProperty(Object object, List propertyNames) { return value; } + + /** + * Wrap an object in it's PyIsh equivalent + * + * @param object Bean. + * @return Wrapped bean. + */ + public Object wrap(Object object) { + return resolver.wrap(object); + } + + public String getAsString(Object object) { + if (interpreter.getConfig().getLegacyOverrides().isUsePyishObjectMapper()) { + return PyishObjectMapper.getAsUnquotedPyishString(object); + } + return Objects.toString(object, ""); + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilder.java b/src/main/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilder.java index e93bba90a..1dc7ad5dd 100644 --- a/src/main/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilder.java +++ b/src/main/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilder.java @@ -1,7 +1,6 @@ package com.hubspot.jinjava.el; import com.hubspot.jinjava.el.ext.ExtendedParser; - import de.odysseus.el.tree.impl.Builder; import de.odysseus.el.tree.impl.Parser; @@ -13,6 +12,7 @@ * */ public class ExtendedSyntaxBuilder extends Builder { + private static final long serialVersionUID = 1L; public ExtendedSyntaxBuilder() { @@ -27,5 +27,4 @@ public ExtendedSyntaxBuilder(Feature... features) { protected Parser createParser(String expression) { return new ExtendedParser(this, expression); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/HasInterpreter.java b/src/main/java/com/hubspot/jinjava/el/HasInterpreter.java new file mode 100644 index 000000000..8b22e98ee --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/HasInterpreter.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +public interface HasInterpreter { + JinjavaInterpreter interpreter(); +} diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaELContext.java b/src/main/java/com/hubspot/jinjava/el/JinjavaELContext.java index eea12c31c..8336b3e95 100644 --- a/src/main/java/com/hubspot/jinjava/el/JinjavaELContext.java +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaELContext.java @@ -1,27 +1,34 @@ package com.hubspot.jinjava.el; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import de.odysseus.el.util.SimpleContext; import java.lang.reflect.Method; - import javax.el.ELResolver; -import de.odysseus.el.util.SimpleContext; - -public class JinjavaELContext extends SimpleContext { +public class JinjavaELContext extends SimpleContext implements HasInterpreter { + private JinjavaInterpreter interpreter; private MacroFunctionMapper functionMapper; + @Deprecated public JinjavaELContext() { super(); } - public JinjavaELContext(ELResolver resolver) { + public JinjavaELContext(JinjavaInterpreter interpreter, ELResolver resolver) { super(resolver); + this.interpreter = interpreter; + } + + @Override + public JinjavaInterpreter interpreter() { + return interpreter; } @Override public MacroFunctionMapper getFunctionMapper() { if (functionMapper == null) { - functionMapper = new MacroFunctionMapper(); + functionMapper = new MacroFunctionMapper(interpreter); } return functionMapper; } @@ -30,5 +37,4 @@ public MacroFunctionMapper getFunctionMapper() { public void setFunction(String prefix, String localName, Method method) { getFunctionMapper().setFunction(prefix, localName, method); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java b/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java index 63eb81f88..c60372b99 100644 --- a/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java @@ -2,80 +2,119 @@ import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.el.ext.AbstractCallableMethod; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.ExtendedParser; +import com.hubspot.jinjava.el.ext.JinjavaBeanELResolver; +import com.hubspot.jinjava.el.ext.JinjavaListELResolver; +import com.hubspot.jinjava.el.ext.NamedParameter; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.objects.PyWrapper; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyList; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; +import com.hubspot.jinjava.objects.collections.SizeLimitingPySet; +import com.hubspot.jinjava.objects.date.FormattedDate; +import com.hubspot.jinjava.objects.date.InvalidDateFormatException; +import com.hubspot.jinjava.objects.date.PyishDate; +import com.hubspot.jinjava.objects.date.StrftimeFormatter; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import com.hubspot.jinjava.util.DeferredValueUtils; +import de.odysseus.el.util.SimpleResolver; import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; - +import java.util.Set; import javax.el.ArrayELResolver; import javax.el.CompositeELResolver; import javax.el.ELContext; import javax.el.ELResolver; -import javax.el.MapELResolver; import javax.el.PropertyNotFoundException; import javax.el.ResourceBundleELResolver; - import org.apache.commons.lang3.LocaleUtils; import org.apache.commons.lang3.StringUtils; -import com.hubspot.jinjava.el.ext.AbstractCallableMethod; -import com.hubspot.jinjava.el.ext.ExtendedParser; -import com.hubspot.jinjava.el.ext.JinjavaBeanELResolver; -import com.hubspot.jinjava.el.ext.JinjavaListELResolver; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.interpret.TemplateError; -import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; -import com.hubspot.jinjava.interpret.TemplateError.ErrorType; -import com.hubspot.jinjava.objects.PyWrapper; -import com.hubspot.jinjava.objects.collections.PyList; -import com.hubspot.jinjava.objects.collections.PyMap; -import com.hubspot.jinjava.objects.date.FormattedDate; -import com.hubspot.jinjava.objects.date.PyishDate; -import com.hubspot.jinjava.objects.date.StrftimeFormatter; - -import de.odysseus.el.util.SimpleResolver; - public class JinjavaInterpreterResolver extends SimpleResolver { - private static final ELResolver DEFAULT_RESOLVER_READ_WRITE = new CompositeELResolver() { + public static final ELResolver DEFAULT_RESOLVER_READ_ONLY = new CompositeELResolver() { + { + add(new ArrayELResolver(true)); + add(new JinjavaListELResolver(true)); + add(new TypeConvertingMapELResolver(true)); + add(new ResourceBundleELResolver()); + add(new JinjavaBeanELResolver(true)); + } + }; + + public static final ELResolver DEFAULT_RESOLVER_READ_WRITE = new CompositeELResolver() { { add(new ArrayELResolver(false)); add(new JinjavaListELResolver(false)); - add(new MapELResolver(false)); + add(new TypeConvertingMapELResolver(false)); add(new ResourceBundleELResolver()); add(new JinjavaBeanELResolver(false)); } }; - private JinjavaInterpreter interpreter; + private final JinjavaInterpreter interpreter; + private final ObjectUnwrapper objectUnwrapper; public JinjavaInterpreterResolver(JinjavaInterpreter interpreter) { - super(DEFAULT_RESOLVER_READ_WRITE); + super(interpreter.getConfig().getElResolver()); this.interpreter = interpreter; + this.objectUnwrapper = interpreter.getConfig().getObjectUnwrapper(); } @Override - public Object invoke(ELContext context, Object base, Object method, - Class[] paramTypes, Object[] params) { - + public Object invoke( + ELContext context, + Object base, + Object method, + Class[] paramTypes, + Object[] params + ) { try { Object methodProperty = getValue(context, base, method, false); - if (methodProperty != null && methodProperty instanceof AbstractCallableMethod) { + if (methodProperty instanceof AbstractCallableMethod) { + Object result = interpreter.getContext().isValidationMode() + ? "" + : ((AbstractCallableMethod) methodProperty).evaluate(params); context.setPropertyResolved(true); - return ((AbstractCallableMethod) methodProperty).evaluate(params); + return result; } } catch (IllegalArgumentException e) { // failed to access property, continue with method calls } - // TODO map named params to special arg in fn to invoke - return super.invoke(context, base, method, paramTypes, params); + try { + return interpreter.getContext().isValidationMode() + ? "" + : super.invoke( + context, + base, + method, + paramTypes, + generateMethodParams(method, params) + ); + } catch (IllegalArgumentException e) { + return null; + } } /** @@ -88,34 +127,156 @@ public Object getValue(ELContext context, Object base, Object property) { return getValue(context, base, property, true); } - private Object getValue(ELContext context, Object base, Object property, boolean errOnUnknownProp) { + /* + * We transform the AST parameters to something meaningful to Jinjava. + * + * Functions, expressions and tags will receive the parameters as they are, but filters + * have a different signature to what they have in the AST to support named parameters, so + * this method transforms their arguments to be the following: + * + * (Left Value, JinjavaInterpreter, Positional Arguments, Named Arguments) + */ + private Object[] generateMethodParams(Object method, Object[] astParams) { + if (!"filter".equals(method)) { + return astParams; // We only change the signature method for filters + } + + if (astParams == null) { + throw new IllegalArgumentException("AST params cannot be null"); + } + + List args = new ArrayList<>(); + Map kwargs = new LinkedHashMap<>(); + + // 2 -> Ignore the Left Value (0) and the JinjavaInterpreter (1) + for (Object param : Arrays.asList(astParams).subList(2, astParams.length)) { + if (param instanceof NamedParameter) { + NamedParameter namedParameter = (NamedParameter) param; + kwargs.put(namedParameter.getName(), namedParameter.getValue()); + } else { + args.add(param); + } + } + + return new Object[] { astParams[0], astParams[1], args.toArray(), kwargs }; + } + + private Object getValue( + ELContext context, + Object base, + Object property, + boolean errOnUnknownProp + ) { String propertyName = Objects.toString(property, ""); Object value = null; - if (ExtendedParser.INTERPRETER.equals(property)) { - value = interpreter; - } else if (propertyName.startsWith(ExtendedParser.FILTER_PREFIX)) { - value = interpreter.getContext().getFilter(StringUtils.substringAfter(propertyName, ExtendedParser.FILTER_PREFIX)); - } else if (propertyName.startsWith(ExtendedParser.EXPTEST_PREFIX)) { - value = interpreter.getContext().getExpTest(StringUtils.substringAfter(propertyName, ExtendedParser.EXPTEST_PREFIX)); - } else { - if (base == null) { - // Look up property in context. - value = interpreter.retraceVariable((String) property, interpreter.getLineNumber()); + interpreter.getContext().addResolvedValue(propertyName); + ErrorItem item = ErrorItem.PROPERTY; + + try { + if (ExtendedParser.INTERPRETER.equals(property)) { + value = interpreter; + } else if (propertyName.startsWith(ExtendedParser.FILTER_PREFIX)) { + item = ErrorItem.FILTER; + value = + interpreter + .getContext() + .getFilter( + StringUtils.substringAfter(propertyName, ExtendedParser.FILTER_PREFIX) + ); + } else if (propertyName.startsWith(ExtendedParser.EXPTEST_PREFIX)) { + item = ErrorItem.EXPRESSION_TEST; + value = + interpreter + .getContext() + .getExpTest( + StringUtils.substringAfter(propertyName, ExtendedParser.EXPTEST_PREFIX) + ); } else { - // Get property of base object. - try { - value = super.getValue(context, base, propertyName); - } catch (PropertyNotFoundException e) { - if (errOnUnknownProp) { - interpreter.addError(TemplateError.fromUnknownProperty(base, propertyName, interpreter.getLineNumber())); + if (base == null) { + // Look up property in context. + value = + interpreter.retraceVariable( + (String) property, + interpreter.getLineNumber(), + -1 + ); + } else { + // Get property of base object. + try { + base = objectUnwrapper.unwrapObject(base); + if (base == null) { + return null; + } + + // java doesn't natively support negative array indices, so the + // super class getValue returns null for them. To make negative + // indices work as they do in python, detect them here and convert + // to the equivalent positive index. + // + // Check for Integer or Long instead of Number so the behavior for a + // floating-point index doesn't change (e.g. -1.5 stays -1.5, it + // doesn't become -1). + if ( + base.getClass().isArray() && + ((property instanceof Integer) || (property instanceof Long)) + ) { + int propertyNum = ((Number) property).intValue(); + if (propertyNum < 0) { + propertyNum += ((Object[]) base).length; + propertyName = String.valueOf(propertyNum); + } + } + + value = super.getValue(context, base, propertyName); + + value = objectUnwrapper.unwrapObject(value); + if (value == null) { + return null; + } + + if (DeferredValueUtils.isFullyDeferred(value)) { + if (interpreter.getConfig().getExecutionMode().useEagerParser()) { + throw new DeferredParsingException(this, propertyName); + } else { + throw new DeferredValueException( + propertyName, + interpreter.getLineNumber(), + interpreter.getPosition() + ); + } + } + } catch (PropertyNotFoundException e) { + if (errOnUnknownProp) { + interpreter.addError( + TemplateError.fromUnknownProperty( + base, + propertyName, + interpreter.getLineNumber(), + -1 + ) + ); + } } } } + } catch (DisabledException e) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.DISABLED, + item, + e.getMessage(), + propertyName, + interpreter.getLineNumber(), + -1, + e + ) + ); } context.setPropertyResolved(true); - return wrap(value); + return value; } @SuppressWarnings("unchecked") @@ -123,20 +284,59 @@ Object wrap(Object value) { if (value == null) { return null; } + if (value instanceof String || value instanceof Number || value instanceof Boolean) { + return value; + } + if (value instanceof PyishSerializable) { + return value; + } + + value = objectUnwrapper.unwrapObject(value); + if (value == null) { + return null; + } if (value instanceof PyWrapper) { return value; } + if (value instanceof Namespace) { + return new SizeLimitingPyMap( + (Namespace) value, + interpreter.getConfig().getMaxMapSize() + ); + } + if (List.class.isAssignableFrom(value.getClass())) { - return new PyList((List) value); + return new SizeLimitingPyList( + (List) value, + interpreter.getConfig().getMaxListSize() + ); + } + if (Set.class.isAssignableFrom(value.getClass())) { + return new SizeLimitingPySet( + (Set) value, + interpreter.getConfig().getMaxListSize() + ); } if (Map.class.isAssignableFrom(value.getClass())) { - return new PyMap((Map) value); + // FIXME: ensure keys are actually strings, if not, convert them + return new SizeLimitingPyMap( + (Map) value, + interpreter.getConfig().getMaxMapSize() + ); } if (Date.class.isAssignableFrom(value.getClass())) { - return new PyishDate(localizeDateTime(interpreter, ZonedDateTime.ofInstant(Instant.ofEpochMilli(((Date) value).getTime()), ZoneOffset.UTC))); + return new PyishDate( + localizeDateTime( + interpreter, + ZonedDateTime.ofInstant( + Instant.ofEpochMilli(((Date) value).getTime()), + ZoneOffset.UTC + ) + ) + ); } if (ZonedDateTime.class.isAssignableFrom(value.getClass())) { return new PyishDate(localizeDateTime(interpreter, (ZonedDateTime) value)); @@ -149,26 +349,54 @@ Object wrap(Object value) { return value; } - private static ZonedDateTime localizeDateTime(JinjavaInterpreter interpreter, ZonedDateTime dt) { - ENGINE_LOG.debug("Using timezone: {} to localize datetime: {}", interpreter.getConfig().getTimeZone(), dt); + private static ZonedDateTime localizeDateTime( + JinjavaInterpreter interpreter, + ZonedDateTime dt + ) { + ENGINE_LOG.debug( + "Using timezone: {} to localize datetime: {}", + interpreter.getConfig().getTimeZone(), + dt + ); return dt.withZoneSameInstant(interpreter.getConfig().getTimeZone()); } - private static String formattedDateToString(JinjavaInterpreter interpreter, FormattedDate d) { - DateTimeFormatter formatter = getFormatter(interpreter, d).withLocale(getLocale(interpreter, d)); - return formatter.format(localizeDateTime(interpreter, d.getDate())); - } + private static String formattedDateToString( + JinjavaInterpreter interpreter, + FormattedDate d + ) { + ZonedDateTime zonedDateTime = localizeDateTime(interpreter, d.getDate()); + Locale locale = getLocale(interpreter, d); - private static DateTimeFormatter getFormatter(JinjavaInterpreter interpreter, FormattedDate d) { if (!StringUtils.isBlank(d.getFormat())) { try { - return StrftimeFormatter.formatter(d.getFormat()); - } catch (IllegalArgumentException e) { - interpreter.addError(new TemplateError(ErrorType.WARNING, ErrorReason.SYNTAX_ERROR, e.getMessage(), null, interpreter.getLineNumber(), null)); + return StrftimeFormatter.format(zonedDateTime, d.getFormat(), locale); + } catch (InvalidDateFormatException e) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.OTHER, + e.getMessage(), + null, + interpreter.getLineNumber(), + -1, + null, + BasicTemplateErrorCategory.UNKNOWN_DATE, + ImmutableMap.of( + "date", + d.getDate().toString(), + "exception", + e.getMessage(), + "lineNumber", + String.valueOf(interpreter.getLineNumber()) + ) + ) + ); } } - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); + return StrftimeFormatter.format(zonedDateTime, "medium", locale); } private static Locale getLocale(JinjavaInterpreter interpreter, FormattedDate d) { @@ -176,11 +404,30 @@ private static Locale getLocale(JinjavaInterpreter interpreter, FormattedDate d) try { return LocaleUtils.toLocale(d.getLanguage()); } catch (IllegalArgumentException e) { - interpreter.addError(new TemplateError(ErrorType.WARNING, ErrorReason.SYNTAX_ERROR, e.getMessage(), null, interpreter.getLineNumber(), null)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.OTHER, + e.getMessage(), + null, + interpreter.getLineNumber(), + -1, + null, + BasicTemplateErrorCategory.UNKNOWN_LOCALE, + ImmutableMap.of( + "date", + d.getDate().toString(), + "exception", + e.getMessage(), + "lineNumber", + String.valueOf(interpreter.getLineNumber()) + ) + ) + ); } } return Locale.US; } - } diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaNodePreProcessor.java b/src/main/java/com/hubspot/jinjava/el/JinjavaNodePreProcessor.java new file mode 100644 index 000000000..64a490945 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaNodePreProcessor.java @@ -0,0 +1,24 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.Node; + +public class JinjavaNodePreProcessor extends JinjavaNodeProcessor { + + @Override + public void accept(Node node, JinjavaInterpreter interpreter) { + interpreter.getContext().setCurrentNode(node); + checkForInterrupt(node); + } + + protected void checkForInterrupt(Node node) { + if (Thread.currentThread().isInterrupted()) { + throw new InterpretException( + "Interrupt rendering " + getClass(), + node.getMaster().getLineNumber(), + node.getMaster().getStartPosition() + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaNodeProcessor.java b/src/main/java/com/hubspot/jinjava/el/JinjavaNodeProcessor.java new file mode 100644 index 000000000..64ebdde4b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaNodeProcessor.java @@ -0,0 +1,11 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.Node; +import java.util.function.BiConsumer; + +public class JinjavaNodeProcessor implements BiConsumer { + + @Override + public void accept(Node node, JinjavaInterpreter interpreter) {} +} diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaObjectUnwrapper.java b/src/main/java/com/hubspot/jinjava/el/JinjavaObjectUnwrapper.java new file mode 100644 index 000000000..338dadeb7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaObjectUnwrapper.java @@ -0,0 +1,25 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.LazyExpression; +import java.util.Optional; + +public class JinjavaObjectUnwrapper implements ObjectUnwrapper { + + @Override + public Object unwrapObject(Object o) { + if (o instanceof LazyExpression) { + o = ((LazyExpression) o).get(); + } + + if (o instanceof Optional) { + Optional optValue = (Optional) o; + if (!optValue.isPresent()) { + return null; + } + + o = optValue.get(); + } + + return o; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaProcessors.java b/src/main/java/com/hubspot/jinjava/el/JinjavaProcessors.java new file mode 100644 index 000000000..01195f64a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaProcessors.java @@ -0,0 +1,59 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.Node; +import java.util.function.BiConsumer; + +public class JinjavaProcessors { + + private final BiConsumer nodePreProcessor; + private final BiConsumer nodePostProcessor; + + private JinjavaProcessors(Builder builder) { + nodePreProcessor = builder.nodePreProcessor; + nodePostProcessor = builder.nodePostProcessor; + } + + public BiConsumer getNodePreProcessor() { + return nodePreProcessor; + } + + public BiConsumer getNodePostProcessor() { + return nodePostProcessor; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(JinjavaProcessors processors) { + return new Builder(processors); + } + + public static class Builder { + + private BiConsumer nodePreProcessor = (n, i) -> {}; + private BiConsumer nodePostProcessor = (n, i) -> {}; + + private Builder() {} + + private Builder(JinjavaProcessors processors) { + this.nodePreProcessor = processors.nodePreProcessor; + this.nodePostProcessor = processors.nodePostProcessor; + } + + public Builder withNodePreProcessor(BiConsumer processor) { + this.nodePreProcessor = processor; + return this; + } + + public Builder withNodePostProcessor(BiConsumer processor) { + this.nodePostProcessor = processor; + return this; + } + + public JinjavaProcessors build() { + return new JinjavaProcessors(this); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/MacroFunctionMapper.java b/src/main/java/com/hubspot/jinjava/el/MacroFunctionMapper.java index c697cdf67..a685abb19 100644 --- a/src/main/java/com/hubspot/jinjava/el/MacroFunctionMapper.java +++ b/src/main/java/com/hubspot/jinjava/el/MacroFunctionMapper.java @@ -1,36 +1,55 @@ package com.hubspot.jinjava.el; +import com.hubspot.jinjava.el.ext.AbstractCallableMethod; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.fn.MacroFunction; import java.lang.reflect.Method; import java.util.Collections; import java.util.HashMap; import java.util.Map; - import javax.el.FunctionMapper; -import com.hubspot.jinjava.el.ext.AbstractCallableMethod; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.fn.MacroFunction; - public class MacroFunctionMapper extends FunctionMapper { + private final JinjavaInterpreter interpreter; private Map map = Collections.emptyMap(); + public MacroFunctionMapper(JinjavaInterpreter interpreter) { + this.interpreter = interpreter; + } + + private static String buildFunctionName(String prefix, String name) { + return prefix + ":" + name; + } + @Override public Method resolveFunction(String prefix, String localName) { - MacroFunction macroFunction = JinjavaInterpreter.getCurrent().getContext().getGlobalMacro(localName); + final Context context = interpreter.getContext(); + MacroFunction macroFunction = context.getGlobalMacro(localName); if (macroFunction != null) { return AbstractCallableMethod.EVAL_METHOD; } - return map.get(prefix + ":" + localName); + final String functionName = buildFunctionName(prefix, localName); + + if (context.isFunctionDisabled(functionName)) { + throw new DisabledException(functionName); + } + + if (map.containsKey(functionName)) { + context.addResolvedFunction(functionName); + } + + return map.get(functionName); } public void setFunction(String prefix, String localName, Method method) { if (map.isEmpty()) { - map = new HashMap(); + map = new HashMap<>(); } - map.put(prefix + ":" + localName, method); + map.put(buildFunctionName(prefix, localName), method); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/NoInvokeELContext.java b/src/main/java/com/hubspot/jinjava/el/NoInvokeELContext.java new file mode 100644 index 000000000..3d3f92f4e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/NoInvokeELContext.java @@ -0,0 +1,43 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import javax.el.ELContext; +import javax.el.ELResolver; +import javax.el.FunctionMapper; +import javax.el.VariableMapper; + +public class NoInvokeELContext extends ELContext implements HasInterpreter { + + private final ELContext delegate; + private NoInvokeELResolver elResolver; + + public NoInvokeELContext(ELContext delegate) { + this.delegate = delegate; + } + + @Override + public ELResolver getELResolver() { + if (elResolver == null) { + elResolver = new NoInvokeELResolver(delegate.getELResolver()); + } + return elResolver; + } + + @Override + public FunctionMapper getFunctionMapper() { + return delegate.getFunctionMapper(); + } + + @Override + public VariableMapper getVariableMapper() { + return delegate.getVariableMapper(); + } + + @Override + public JinjavaInterpreter interpreter() { + if (delegate instanceof HasInterpreter hasInterpreter) { + return hasInterpreter.interpreter(); + } + return JinjavaInterpreter.getCurrent(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/NoInvokeELResolver.java b/src/main/java/com/hubspot/jinjava/el/NoInvokeELResolver.java new file mode 100644 index 000000000..41f2256c0 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/NoInvokeELResolver.java @@ -0,0 +1,65 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import java.beans.FeatureDescriptor; +import java.util.Iterator; +import javax.el.ELContext; +import javax.el.ELResolver; + +/** + * An ELResolver that is read only and does not allow invocation of methods. + * It is unknown whether the results of these resolver calls will be committed, + * so disallows modification and invocation which may result in modification of values. + */ +public class NoInvokeELResolver extends ELResolver { + + private ELResolver delegate; + + public NoInvokeELResolver(ELResolver delegate) { + this.delegate = delegate; + } + + @Override + public Class getCommonPropertyType(ELContext elContext, Object base) { + return delegate.getCommonPropertyType(elContext, base); + } + + @Override + public Iterator getFeatureDescriptors( + ELContext elContext, + Object base + ) { + return delegate.getFeatureDescriptors(elContext, base); + } + + @Override + public Class getType(ELContext elContext, Object base, Object property) { + return delegate.getType(elContext, base, property); + } + + @Override + public Object getValue(ELContext elContext, Object base, Object property) { + return delegate.getValue(elContext, base, property); + } + + @Override + public boolean isReadOnly(ELContext elContext, Object base, Object property) { + return true; + } + + @Override + public void setValue(ELContext elContext, Object base, Object property, Object value) { + throw new DeferredValueException("NoInvokeELResolver"); + } + + @Override + public Object invoke( + ELContext context, + Object base, + Object method, + Class[] paramTypes, + Object[] params + ) { + throw new DeferredValueException("NoInvokeELResolver"); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ObjectUnwrapper.java b/src/main/java/com/hubspot/jinjava/el/ObjectUnwrapper.java new file mode 100644 index 000000000..2d04ad6ec --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ObjectUnwrapper.java @@ -0,0 +1,5 @@ +package com.hubspot.jinjava.el; + +public interface ObjectUnwrapper { + Object unwrapObject(Object o); +} diff --git a/src/main/java/com/hubspot/jinjava/el/ReturnTypeValidatingJinjavaInterpreterResolver.java b/src/main/java/com/hubspot/jinjava/el/ReturnTypeValidatingJinjavaInterpreterResolver.java new file mode 100644 index 000000000..0a874f5e1 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ReturnTypeValidatingJinjavaInterpreterResolver.java @@ -0,0 +1,73 @@ +package com.hubspot.jinjava.el; + +import com.hubspot.jinjava.el.ext.AllowlistReturnTypeValidator; +import java.beans.FeatureDescriptor; +import java.util.Iterator; +import javax.el.ELContext; +import javax.el.ELResolver; + +class ReturnTypeValidatingJinjavaInterpreterResolver extends ELResolver { + + private final AllowlistReturnTypeValidator returnTypeValidator; + private final JinjavaInterpreterResolver delegate; + + ReturnTypeValidatingJinjavaInterpreterResolver( + AllowlistReturnTypeValidator returnTypeValidator, + JinjavaInterpreterResolver delegate + ) { + this.returnTypeValidator = returnTypeValidator; + this.delegate = delegate; + } + + @Override + public Class getCommonPropertyType(ELContext context, Object base) { + return delegate.getCommonPropertyType(context, base); + } + + @Override + public Iterator getFeatureDescriptors( + ELContext context, + Object base + ) { + return delegate.getFeatureDescriptors(context, base); + } + + @Override + public Class getType(ELContext context, Object base, Object property) { + return delegate.getType(context, base, property); + } + + @Override + public Object getValue(ELContext context, Object base, Object property) { + return returnTypeValidator.validateReturnType( + wrap(delegate.getValue(context, base, property)) + ); + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + return delegate.isReadOnly(context, base, property); + } + + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + delegate.setValue(context, base, property, value); + } + + @Override + public Object invoke( + ELContext context, + Object base, + Object method, + Class[] paramTypes, + Object[] params + ) { + return returnTypeValidator.validateReturnType( + wrap(delegate.invoke(context, base, method, paramTypes, params)) + ); + } + + Object wrap(Object object) { + return delegate.wrap(object); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/TruthyTypeConverter.java b/src/main/java/com/hubspot/jinjava/el/TruthyTypeConverter.java index cc0046130..25babab14 100644 --- a/src/main/java/com/hubspot/jinjava/el/TruthyTypeConverter.java +++ b/src/main/java/com/hubspot/jinjava/el/TruthyTypeConverter.java @@ -1,15 +1,173 @@ package com.hubspot.jinjava.el; +import com.hubspot.jinjava.objects.DummyObject; import com.hubspot.jinjava.util.ObjectTruthValue; - import de.odysseus.el.misc.TypeConverterImpl; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collection; +import java.util.EnumSet; +import java.util.Iterator; +import javax.el.ELException; public class TruthyTypeConverter extends TypeConverterImpl { + private static final long serialVersionUID = 1L; + public static final int MAX_COLLECTION_STRING_LENGTH = 1_000_000; @Override protected Boolean coerceToBoolean(Object value) { - return ObjectTruthValue.evaluate(value); + return Boolean.valueOf(ObjectTruthValue.evaluate(value)); + } + + @Override + protected Character coerceToCharacter(Object value) { + if (value instanceof DummyObject) { + return '0'; + } + return super.coerceToCharacter(value); + } + + @Override + protected BigDecimal coerceToBigDecimal(Object value) { + if (value instanceof DummyObject) { + return BigDecimal.ZERO; + } + return super.coerceToBigDecimal(value); + } + + @Override + protected BigInteger coerceToBigInteger(Object value) { + if (value instanceof DummyObject) { + return BigInteger.ZERO; + } + return super.coerceToBigInteger(value); + } + + @Override + protected Double coerceToDouble(Object value) { + if (value instanceof DummyObject) { + return 0d; + } + return super.coerceToDouble(value); + } + + @Override + protected Float coerceToFloat(Object value) { + if (value instanceof DummyObject) { + return 0f; + } + return super.coerceToFloat(value); + } + + @Override + protected Long coerceToLong(Object value) { + if (value instanceof DummyObject) { + return 0L; + } + return super.coerceToLong(value); + } + + @Override + protected Integer coerceToInteger(Object value) { + if (value instanceof DummyObject) { + return 0; + } + return super.coerceToInteger(value); } + @Override + protected Short coerceToShort(Object value) { + if (value instanceof DummyObject) { + return 0; + } + return super.coerceToShort(value); + } + + @Override + protected Byte coerceToByte(Object value) { + if (value instanceof DummyObject) { + return 0; + } + return super.coerceToByte(value); + } + + @Override + protected String coerceToString(Object value) { + if (value instanceof DummyObject) { + return ""; + } + if (value == null) { + return ""; + } + if (value instanceof String) { + return (String) value; + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + + if (value instanceof Collection) { + return coerceCollection((Collection) value); + } + + return value.toString(); + } + + private String coerceCollection(Collection value) { + Iterator it = value.iterator(); + if (!it.hasNext()) { + return "[]"; + } + + StringBuilder sb = new StringBuilder(); + + sb.append('['); + for (;;) { + Object e = it.next(); + sb.append(e == this ? "(this Collection)" : e); + if (sb.length() > MAX_COLLECTION_STRING_LENGTH) { + return sb.append(", ...]").toString(); + } + if (!it.hasNext()) { + return sb.append(']').toString(); + } + sb.append(','); + sb.append(' '); + } + } + + @Override + protected > T coerceToEnum(Object value, Class type) { + if (value instanceof DummyObject) { + EnumSet enumSet = EnumSet.allOf(type); + if (!enumSet.isEmpty()) { + return enumSet.iterator().next(); + } + } + + try { + return super.coerceToEnum(value, type); + } catch (ELException e) { + if (value instanceof String) { + for (T enumVal : type.getEnumConstants()) { + String enumStr = enumVal.toString(); + if (enumStr != null && enumStr.equalsIgnoreCase((String) value)) { + return enumVal; + } + } + } + throw e; + } + } + + @Override + protected Object coerceStringToType(String value, Class type) { + return super.coerceStringToType(value, type); + } + + @Override + protected Object coerceToType(Object value, Class type) { + return super.coerceToType(value, type); + } } diff --git a/src/main/java/com/hubspot/jinjava/el/TypeConvertingMapELResolver.java b/src/main/java/com/hubspot/jinjava/el/TypeConvertingMapELResolver.java new file mode 100644 index 000000000..1c6304e7b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/TypeConvertingMapELResolver.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.el; + +import java.util.Iterator; +import java.util.Map; +import javax.el.ELContext; +import javax.el.ELException; +import javax.el.MapELResolver; + +public class TypeConvertingMapELResolver extends MapELResolver { + + private static final TruthyTypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + public TypeConvertingMapELResolver(boolean readOnly) { + super(readOnly); + } + + @Override + public Object getValue(ELContext context, Object base, Object property) { + Object value = super.getValue(context, base, property); + + if (value != null) { + return value; + } + + if (base instanceof Map && !((Map) base).isEmpty()) { + Iterator iterator = ((Map) base).keySet().iterator(); + Class keyClass = null; + while (iterator.hasNext()) { + Object nextObject = iterator.next(); + if (nextObject != null) { + keyClass = nextObject.getClass(); + break; + } + } + + if (keyClass == null) { + value = ((Map) base).get(property); + } else { + try { + value = ((Map) base).get(TYPE_CONVERTER.convert(property, keyClass)); + if (value != null) { + context.setPropertyResolved(true); + } + } catch (ELException ex) { + value = null; + } + } + } + + return value; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AbsOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/AbsOperator.java index 21e75031a..5fc001db4 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AbsOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AbsOperator.java @@ -1,5 +1,6 @@ package com.hubspot.jinjava.el.ext; +import com.hubspot.jinjava.el.ext.eager.EagerAstUnary; import de.odysseus.el.misc.TypeConverter; import de.odysseus.el.tree.impl.Parser.ExtensionHandler; import de.odysseus.el.tree.impl.Parser.ExtensionPoint; @@ -7,38 +8,50 @@ import de.odysseus.el.tree.impl.Scanner.ExtensionToken; import de.odysseus.el.tree.impl.ast.AstNode; import de.odysseus.el.tree.impl.ast.AstUnary; +import de.odysseus.el.tree.impl.ast.AstUnary.SimpleOperator; -public class AbsOperator { +public class AbsOperator extends SimpleOperator { public static final ExtensionToken TOKEN = new Scanner.ExtensionToken("+"); + public static final AbsOperator OP = new AbsOperator(); - public static final ExtensionHandler HANDLER = new ExtensionHandler(ExtensionPoint.UNARY) { - @Override - public AstNode createAstNode(AstNode... children) { - return new AstUnary(children[0], new AstUnary.SimpleOperator() { - @Override - protected Object apply(TypeConverter converter, Object o) { - if (o == null) { - return null; - } - - if (o instanceof Float) { - return Math.abs((Float) o); - } - if (o instanceof Double) { - return Math.abs((Double) o); - } - if (o instanceof Integer) { - return Math.abs((Integer) o); - } - if (o instanceof Long) { - return Math.abs((Long) o); - } - - throw new IllegalArgumentException("Unable to apply abs operator on object of type: " + o.getClass()); - } - }); + public static final ExtensionHandler HANDLER = getHandler(false); + + @Override + protected Object apply(TypeConverter converter, Object o) { + if (o == null) { + return null; + } + + if (o instanceof Float) { + return Math.abs((Float) o); + } + if (o instanceof Double) { + return Math.abs((Double) o); } - }; + if (o instanceof Integer) { + return Math.abs((Integer) o); + } + if (o instanceof Long) { + return Math.abs((Long) o); + } + + throw new IllegalArgumentException( + "Unable to apply abs operator on object of type: " + o.getClass() + ); + } + + @Override + public String toString() { + return "+"; + } + public static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.UNARY) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager ? new EagerAstUnary(children[0], OP) : new AstUnary(children[0], OP); + } + }; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AbstractCallableMethod.java b/src/main/java/com/hubspot/jinjava/el/ext/AbstractCallableMethod.java index 2ef139c2e..91b95550c 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AbstractCallableMethod.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AbstractCallableMethod.java @@ -1,13 +1,12 @@ package com.hubspot.jinjava.el.ext; +import com.google.common.base.Throwables; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import com.google.common.base.Throwables; - /** * Defines a function which will be called in the context of an interpreter instance. Supports named params with default values, as well as var args. * @@ -17,18 +16,23 @@ public abstract class AbstractCallableMethod { public static final Method EVAL_METHOD; + static { try { EVAL_METHOD = AbstractCallableMethod.class.getMethod("evaluate", Object[].class); } catch (Exception e) { - throw Throwables.propagate(e); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); } } - private String name; - private LinkedHashMap argNamesWithDefaults; + private final String name; + private final LinkedHashMap argNamesWithDefaults; - public AbstractCallableMethod(String name, LinkedHashMap argNamesWithDefaults) { + public AbstractCallableMethod( + String name, + LinkedHashMap argNamesWithDefaults + ) { this.name = name; this.argNamesWithDefaults = argNamesWithDefaults; } @@ -48,8 +52,7 @@ public Object evaluate(Object... args) { break; } argEntry.setValue(arg); - } - else { + } else { break; } } @@ -61,12 +64,10 @@ public Object evaluate(Object... args) { NamedParameter param = (NamedParameter) arg; if (argMap.containsKey(param.getName())) { argMap.put(param.getName(), param.getValue()); - } - else { + } else { kwargMap.put(param.getName(), param.getValue()); } - } - else { + } else { varArgs.add(arg); } } @@ -74,7 +75,11 @@ public Object evaluate(Object... args) { return doEvaluate(argMap, kwargMap, varArgs); } - public abstract Object doEvaluate(Map argMap, Map kwargMap, List varArgs); + public abstract Object doEvaluate( + Map argMap, + Map kwargMap, + List varArgs + ); public String getName() { return name; @@ -87,5 +92,4 @@ public List getArguments() { public Map getDefaults() { return argNamesWithDefaults; } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AdditionOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/AdditionOperator.java index 555e438be..b9f6a99b6 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AdditionOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AdditionOperator.java @@ -1,5 +1,8 @@ package com.hubspot.jinjava.el.ext; +import de.odysseus.el.misc.NumberOperations; +import de.odysseus.el.misc.TypeConverter; +import de.odysseus.el.tree.impl.ast.AstBinary; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -7,11 +10,9 @@ import java.util.Map; import java.util.Objects; -import de.odysseus.el.misc.NumberOperations; -import de.odysseus.el.misc.TypeConverter; -import de.odysseus.el.tree.impl.ast.AstBinary; - -public class AdditionOperator extends AstBinary.SimpleOperator { +public class AdditionOperator + extends AstBinary.SimpleOperator + implements StringBuildingOperator { @SuppressWarnings("unchecked") @Override @@ -21,14 +22,12 @@ protected Object apply(TypeConverter converter, Object o1, Object o2) { if (o2 instanceof Collection) { result.addAll((Collection) o2); - } - else { + } else { result.add(o2); } return result; - } - else if (o1 instanceof Map && o2 instanceof Map) { + } else if (o1 instanceof Map && o2 instanceof Map) { Map result = new HashMap<>((Map) o1); result.putAll((Map) o2); @@ -36,11 +35,19 @@ else if (o1 instanceof Map && o2 instanceof Map) { } if (o1 instanceof String || o2 instanceof String) { - return Objects.toString(o1).concat(Objects.toString(o2)); + return getStringBuilder() + .append(Objects.toString(o1)) + .append(Objects.toString(o2)) + .toString(); } return NumberOperations.add(converter, o1, o2); } + @Override + public String toString() { + return "+"; + } + public static final AdditionOperator OP = new AdditionOperator(); } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AllowlistGroup.java b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistGroup.java new file mode 100644 index 000000000..0205f572a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistGroup.java @@ -0,0 +1,211 @@ +package com.hubspot.jinjava.el.ext; + +import com.google.common.collect.ForwardingCollection; +import com.google.common.collect.ForwardingList; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ForwardingSet; +import com.hubspot.jinjava.interpret.NullValue; +import com.hubspot.jinjava.lib.exptest.ExpTest; +import com.hubspot.jinjava.lib.filter.Filter; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.objects.DummyObject; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.objects.SafeString; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.collections.PySet; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyList; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; +import com.hubspot.jinjava.objects.collections.SizeLimitingPySet; +import com.hubspot.jinjava.objects.collections.SnakeCaseAccessibleMap; +import com.hubspot.jinjava.objects.date.FormattedDate; +import com.hubspot.jinjava.objects.date.PyishDate; +import com.hubspot.jinjava.util.ForLoop; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +public enum AllowlistGroup { + JavaPrimitives { + private static final String[] ARRAY = { + String.class.getCanonicalName(), + Long.class.getCanonicalName(), + Integer.class.getCanonicalName(), + Double.class.getCanonicalName(), + Byte.class.getCanonicalName(), + Character.class.getCanonicalName(), + Float.class.getCanonicalName(), + Boolean.class.getCanonicalName(), + Short.class.getCanonicalName(), + long.class.getCanonicalName(), + int.class.getCanonicalName(), + double.class.getCanonicalName(), + byte.class.getCanonicalName(), + char.class.getCanonicalName(), + float.class.getCanonicalName(), + boolean.class.getCanonicalName(), + short.class.getCanonicalName(), + BigDecimal.class.getCanonicalName(), + BigInteger.class.getCanonicalName(), + }; + + @Override + String[] allowedReturnTypeClasses() { + return ARRAY; + } + + @Override + String[] allowedDeclaredMethodsFromClasses() { + return ARRAY; + } + }, + JinjavaObjects { + private static final String[] ARRAY = { + PyList.class.getCanonicalName(), + PyMap.class.getCanonicalName(), + PySet.class.getCanonicalName(), + SizeLimitingPyMap.class.getCanonicalName(), + SizeLimitingPyList.class.getCanonicalName(), + SizeLimitingPySet.class.getCanonicalName(), + SnakeCaseAccessibleMap.class.getCanonicalName(), + FormattedDate.class.getCanonicalName(), + PyishDate.class.getCanonicalName(), + DummyObject.class.getCanonicalName(), + Namespace.class.getCanonicalName(), + SafeString.class.getCanonicalName(), + NullValue.class.getCanonicalName(), + }; + + @Override + String[] allowedReturnTypeClasses() { + return ARRAY; + } + + @Override + String[] allowedDeclaredMethodsFromClasses() { + return ARRAY; + } + }, + Collections { + private static final String[] ARRAY = { + Map.Entry.class.getCanonicalName(), + PyList.class.getCanonicalName(), + PyMap.class.getCanonicalName(), + PySet.class.getCanonicalName(), + SizeLimitingPyMap.class.getCanonicalName(), + SizeLimitingPyList.class.getCanonicalName(), + SizeLimitingPySet.class.getCanonicalName(), + ArrayList.class.getCanonicalName(), + ForwardingList.class.getCanonicalName(), + ForwardingMap.class.getCanonicalName(), + ForwardingSet.class.getCanonicalName(), + ForwardingCollection.class.getCanonicalName(), + LinkedHashMap.class.getCanonicalName(), + "%s.Entry".formatted(LinkedHashMap.class.getCanonicalName()), + "%s.LinkedValues".formatted(LinkedHashMap.class.getCanonicalName()), + }; + + @Override + String[] allowedReturnTypeClasses() { + return ARRAY; + } + + @Override + String[] allowedDeclaredMethodsFromClasses() { + return ARRAY; + } + + @Override + boolean enableArrays() { + return true; + } + }, + JinjavaTagConstructs { + private static final String[] ARRAY = { + ForLoop.class.getCanonicalName(), + MacroFunction.class.getCanonicalName(), + EagerMacroFunction.class.getCanonicalName(), + }; + + @Override + String[] allowedReturnTypeClasses() { + return ARRAY; + } + + @Override + String[] allowedDeclaredMethodsFromClasses() { + return ARRAY; + } + }, + JinjavaFilters { + private static final String[] ARRAY = { Filter.class.getPackageName() + '.' }; + + @Override + String[] allowedDeclaredMethodsFromCanonicalClassPrefixes() { + return ARRAY; + } + + @Override + String[] allowedReturnTypeCanonicalClassPrefixes() { + return ARRAY; + } + }, + JinjavaFunctions { + private static final String[] ARRAY = { + ZonedDateTime.class.getCanonicalName(), + NamedParameter.class.getCanonicalName(), + }; + + @Override + String[] allowedDeclaredMethodsFromClasses() { + return ARRAY; + } + + @Override + String[] allowedReturnTypeClasses() { + return ARRAY; + } + }, + JinjavaExpTests { + private static final String[] ARRAY = { ExpTest.class.getPackageName() + '.' }; + + @Override + String[] allowedDeclaredMethodsFromCanonicalClassPrefixes() { + return ARRAY; + } + + @Override + String[] allowedReturnTypeCanonicalClassPrefixes() { + return ARRAY; + } + }; + + Method[] allowMethods() { + return new Method[0]; + } + + String[] allowedDeclaredMethodsFromCanonicalClassPrefixes() { + return new String[0]; + } + + String[] allowedDeclaredMethodsFromClasses() { + return new String[0]; + } + + String[] allowedReturnTypeCanonicalClassPrefixes() { + return new String[0]; + } + + String[] allowedReturnTypeClasses() { + return new String[0]; + } + + boolean enableArrays() { + return false; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AllowlistMethodValidator.java b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistMethodValidator.java new file mode 100644 index 000000000..44f67a700 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistMethodValidator.java @@ -0,0 +1,85 @@ +package com.hubspot.jinjava.el.ext; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +public final class AllowlistMethodValidator { + + public static final AllowlistMethodValidator DEFAULT = AllowlistMethodValidator.create( + MethodValidatorConfig.builder().addDefaultAllowlistGroups().build() + ); + private final ConcurrentHashMap allowedMethodsCache; + private final ImmutableSet allowedMethods; + private final ImmutableSet allowedDeclaredMethodsFromCanonicalClassPrefixes; + private final ImmutableSet allowedDeclaredMethodsFromCanonicalClassNames; + private final Consumer onRejectedMethod; + private final ImmutableList additionalValidators; + + public static AllowlistMethodValidator create( + MethodValidatorConfig methodValidatorConfig, + MethodValidator... additionalValidators + ) { + return new AllowlistMethodValidator( + methodValidatorConfig, + ImmutableList.copyOf(additionalValidators) + ); + } + + private AllowlistMethodValidator( + MethodValidatorConfig methodValidatorConfig, + ImmutableList additionalValidators + ) { + this.allowedMethods = methodValidatorConfig.allowedMethods(); + this.allowedDeclaredMethodsFromCanonicalClassPrefixes = + methodValidatorConfig.allowedDeclaredMethodsFromCanonicalClassPrefixes(); + this.allowedDeclaredMethodsFromCanonicalClassNames = + methodValidatorConfig.allowedDeclaredMethodsFromCanonicalClassNames(); + this.onRejectedMethod = methodValidatorConfig.onRejectedMethod(); + this.additionalValidators = additionalValidators; + this.allowedMethodsCache = new ConcurrentHashMap<>(); + } + + public Method validateMethod(Method m) { + if (m == null) { + return null; + } + boolean isAllowedMethod = allowedMethodsCache.computeIfAbsent( + m, + m1 -> { + Class clazz = m1.getDeclaringClass(); + String canonicalClassName = clazz.getCanonicalName(); + if (canonicalClassName == null) { + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass.isEnum()) { + canonicalClassName = superclass.getCanonicalName(); + } + } + if (canonicalClassName == null) { + return false; + } + return ( + allowedMethods.contains(m1) || + allowedDeclaredMethodsFromCanonicalClassNames.contains(canonicalClassName) || + allowedDeclaredMethodsFromCanonicalClassPrefixes + .stream() + .anyMatch(canonicalClassName::startsWith) + ); + } + ); + if (!isAllowedMethod) { + onRejectedMethod.accept(m); + return null; + } + for (MethodValidator v : additionalValidators) { + if (v.validateMethod(m) == null) { + onRejectedMethod.accept(m); + return null; + } + } + + return m; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AllowlistReturnTypeValidator.java b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistReturnTypeValidator.java new file mode 100644 index 000000000..58195170a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/AllowlistReturnTypeValidator.java @@ -0,0 +1,82 @@ +package com.hubspot.jinjava.el.ext; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +public final class AllowlistReturnTypeValidator { + + public static final AllowlistReturnTypeValidator DEFAULT = + AllowlistReturnTypeValidator.create( + ReturnTypeValidatorConfig.builder().addDefaultAllowlistGroups().build() + ); + private final ConcurrentHashMap, Boolean> allowedReturnTypesCache; + + private final ImmutableSet allowedCanonicalClassPrefixes; + private final ImmutableSet allowedCanonicalClassNames; + private final boolean allowArrays; + private final Consumer> onRejectedClass; + private final ImmutableList additionalValidators; + + public static AllowlistReturnTypeValidator create( + ReturnTypeValidatorConfig returnTypeValidatorConfig, + ReturnTypeValidator... additionalValidators + ) { + return new AllowlistReturnTypeValidator( + returnTypeValidatorConfig, + ImmutableList.copyOf(additionalValidators) + ); + } + + private AllowlistReturnTypeValidator( + ReturnTypeValidatorConfig returnTypeValidatorConfig, + ImmutableList additionalValidators + ) { + this.allowedCanonicalClassPrefixes = + returnTypeValidatorConfig.allowedCanonicalClassPrefixes(); + this.allowedCanonicalClassNames = + returnTypeValidatorConfig.allowedCanonicalClassNames(); + this.allowArrays = returnTypeValidatorConfig.allowArrays(); + this.onRejectedClass = returnTypeValidatorConfig.onRejectedClass(); + this.additionalValidators = additionalValidators; + this.allowedReturnTypesCache = new ConcurrentHashMap<>(); + } + + public Object validateReturnType(Object o) { + if (o == null) { + return null; + } + if (o instanceof String || o instanceof Number || o instanceof Boolean) { + return o; + } + Class clazz = o instanceof Enum ? ((Enum) o).getDeclaringClass() : o.getClass(); + if (clazz.isArray() && allowArrays) { + return o; + } + boolean isAllowedClassName = allowedReturnTypesCache.computeIfAbsent( + clazz, + c -> { + String canonicalClassName = c.getCanonicalName(); + if (canonicalClassName == null) { + return false; + } + return ( + allowedCanonicalClassNames.contains(canonicalClassName) || + allowedCanonicalClassPrefixes.stream().anyMatch(canonicalClassName::startsWith) + ); + } + ); + if (!isAllowedClassName) { + onRejectedClass.accept(clazz); + return null; + } + for (ReturnTypeValidator v : additionalValidators) { + if (v.validateReturnType(o) == null) { + onRejectedClass.accept(clazz); + return null; + } + } + return o; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstDict.java b/src/main/java/com/hubspot/jinjava/el/ext/AstDict.java index 70aa1eb0c..d92acf7b3 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstDict.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstDict.java @@ -1,22 +1,23 @@ package com.hubspot.jinjava.el.ext; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -import javax.el.ELContext; - -import com.hubspot.jinjava.objects.collections.PyMap; - +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateStateException; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; import de.odysseus.el.tree.Bindings; import de.odysseus.el.tree.impl.ast.AstIdentifier; import de.odysseus.el.tree.impl.ast.AstLiteral; import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstNumber; import de.odysseus.el.tree.impl.ast.AstString; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import javax.el.ELContext; public class AstDict extends AstLiteral { - private Map dict; + protected final Map dict; public AstDict(Map dict) { this.dict = dict; @@ -24,30 +25,47 @@ public AstDict(Map dict) { @Override public Object eval(Bindings bindings, ELContext context) { - Map resolved = new HashMap<>(); + Map resolved = new LinkedHashMap<>(); + JinjavaInterpreter interpreter = ((HasInterpreter) context).interpreter(); for (Map.Entry entry : dict.entrySet()) { String key; - - if (entry.getKey() instanceof AstString) { - key = Objects.toString(entry.getKey().eval(bindings, context)); - } - else if (entry.getKey() instanceof AstIdentifier) { - key = ((AstIdentifier) entry.getKey()).getName(); - } - else { - throw new IllegalArgumentException("Dict key must be a string or identifier, was: " + entry.getKey()); + AstNode entryKey = entry.getKey(); + + if (entryKey instanceof AstString) { + key = Objects.toString(entryKey.eval(bindings, context)); + } else if (entryKey instanceof AstIdentifier) { + if (interpreter.getConfig().getLegacyOverrides().isEvaluateMapKeys()) { + Object result = entryKey.eval(bindings, context); + key = + result == null + ? ((AstIdentifier) entryKey).getName() // this is for compatibility with the previous behavior + : result.toString(); + } else { + key = ((AstIdentifier) entryKey).getName(); + } + } else if (entryKey instanceof AstNumber) { + // This is a hack to treat numeric keys as string keys in the dictionary. + // In most cases this is adequate since the keys are typically treated as + // strings. + key = Objects.toString(entryKey.eval(bindings, context)); + } else { + throw new TemplateStateException( + "Dict key must be a string, or identifier, or a number, was: " + entryKey + ); } resolved.put(key, entry.getValue().eval(bindings, context)); } - return new PyMap(resolved); + return new SizeLimitingPyMap(resolved, interpreter.getConfig().getMaxMapSize()); } @Override public void appendStructure(StringBuilder builder, Bindings bindings) { - throw new UnsupportedOperationException("TODO"); + throw new UnsupportedOperationException( + "appendStructure not implemented in " + getClass().getSimpleName() + ); } @Override @@ -55,10 +73,9 @@ public String toString() { StringBuilder s = new StringBuilder("{"); for (Map.Entry entry : dict.entrySet()) { - s.append(Objects.toString(entry.getKey())).append(":").append(Objects.toString(entry.getValue())); + s.append(entry.getKey()).append(":").append(entry.getValue()); } return s.append("}").toString(); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java new file mode 100644 index 000000000..bb29d5694 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java @@ -0,0 +1,206 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.lib.filter.Filter; +import com.hubspot.jinjava.objects.SafeString; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstParameters; +import de.odysseus.el.tree.impl.ast.AstRightValue; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.el.ELContext; +import javax.el.ELException; + +/** + * AST node for a chain of filters applied to an input expression. + * Instead of creating nested AstMethod calls for each filter in a chain like: + * filter:length.filter(filter:lower.filter(filter:trim.filter(input))) + * + * This node represents the entire chain as a single evaluation unit: + * input|trim|lower|length + * + * This optimization reduces: + * - Filter lookups (done once per filter instead of per AST node traversal) + * - Method invocation overhead + * - Object wrapping/unwrapping between filters + * - Context operations + */ +public class AstFilterChain extends AstRightValue { + + protected final AstNode input; + protected final List filterSpecs; + + public AstFilterChain(AstNode input, List filterSpecs) { + this.input = Objects.requireNonNull(input, "Input node cannot be null"); + this.filterSpecs = Objects.requireNonNull(filterSpecs, "Filter specs cannot be null"); + if (filterSpecs.isEmpty()) { + throw new IllegalArgumentException("Filter chain must have at least one filter"); + } + } + + public AstNode getInput() { + return input; + } + + public List getFilterSpecs() { + return filterSpecs; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + JinjavaInterpreter interpreter = getInterpreter(context); + + if (interpreter.getContext().isValidationMode()) { + return ""; + } + + Object value = input.eval(bindings, context); + + for (FilterSpec spec : filterSpecs) { + String filterKey = ExtendedParser.FILTER_PREFIX + spec.getName(); + interpreter.getContext().addResolvedValue(filterKey); + + Filter filter; + try { + filter = interpreter.getContext().getFilter(spec.getName()); + } catch (DisabledException e) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.DISABLED, + ErrorItem.FILTER, + e.getMessage(), + spec.getName(), + interpreter.getLineNumber(), + -1, + e + ) + ); + value = null; + continue; + } + if (filter == null) { + value = null; + continue; + } + + Object[] args = evaluateFilterArgs(spec, bindings, context); + Map kwargs = extractNamedParams(args); + Object[] positionalArgs = extractPositionalArgs(args); + + boolean wasSafeString = value instanceof SafeString; + if (wasSafeString) { + value = value.toString(); + } + + try { + value = filter.filter(value, interpreter, positionalArgs, kwargs); + } catch (ELException e) { + throw e; + } catch (RuntimeException e) { + throw new ELException( + String.format("Error in filter '%s': %s", spec.getName(), e.getMessage()), + e + ); + } + + if (wasSafeString && filter.preserveSafeString() && value instanceof String) { + value = new SafeString((String) value); + } + } + + return value; + } + + protected JinjavaInterpreter getInterpreter(ELContext context) { + return ((HasInterpreter) context).interpreter(); + } + + protected Object[] evaluateFilterArgs( + FilterSpec spec, + Bindings bindings, + ELContext context + ) { + AstParameters params = spec.getParams(); + if (params == null || params.getCardinality() == 0) { + return new Object[0]; + } + + Object[] args = new Object[params.getCardinality()]; + for (int i = 0; i < params.getCardinality(); i++) { + args[i] = params.getChild(i).eval(bindings, context); + } + return args; + } + + private Map extractNamedParams(Object[] args) { + Map kwargs = new LinkedHashMap<>(); + for (Object arg : args) { + if (arg instanceof NamedParameter) { + NamedParameter namedParam = (NamedParameter) arg; + kwargs.put(namedParam.getName(), namedParam.getValue()); + } + } + return kwargs; + } + + private Object[] extractPositionalArgs(Object[] args) { + List positional = new ArrayList<>(); + for (Object arg : args) { + if (!(arg instanceof NamedParameter)) { + positional.add(arg); + } + } + return positional.toArray(); + } + + @Override + public void appendStructure(StringBuilder builder, Bindings bindings) { + input.appendStructure(builder, bindings); + for (FilterSpec spec : filterSpecs) { + builder.append('|').append(spec.getName()); + AstParameters params = spec.getParams(); + if (params != null && params.getCardinality() > 0) { + params.appendStructure(builder, bindings); + } + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(input.toString()); + for (FilterSpec spec : filterSpecs) { + sb.append('|').append(spec.toString()); + } + return sb.toString(); + } + + @Override + public int getCardinality() { + return 1 + filterSpecs.size(); + } + + @Override + public AstNode getChild(int i) { + if (i == 0) { + return input; + } + int filterIndex = i - 1; + if (filterIndex < filterSpecs.size()) { + FilterSpec spec = filterSpecs.get(filterIndex); + return spec.getParams(); + } + return null; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstList.java b/src/main/java/com/hubspot/jinjava/el/ext/AstList.java index f18d8f131..163548a2e 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstList.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstList.java @@ -1,21 +1,19 @@ package com.hubspot.jinjava.el.ext; +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyList; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstLiteral; +import de.odysseus.el.tree.impl.ast.AstParameters; import java.util.ArrayList; import java.util.List; - import javax.el.ELContext; - import org.apache.commons.lang3.StringUtils; -import com.hubspot.jinjava.objects.collections.PyList; - -import de.odysseus.el.tree.Bindings; -import de.odysseus.el.tree.impl.ast.AstLiteral; -import de.odysseus.el.tree.impl.ast.AstParameters; - public class AstList extends AstLiteral { - private AstParameters elements; + protected final AstParameters elements; public AstList(AstParameters elements) { this.elements = elements; @@ -29,12 +27,16 @@ public Object eval(Bindings bindings, ELContext context) { list.add(elements.getChild(i).eval(bindings, context)); } - return new PyList(list); + JinjavaInterpreter interpreter = ((HasInterpreter) context).interpreter(); + + return new SizeLimitingPyList(list, interpreter.getConfig().getMaxListSize()); } @Override public void appendStructure(StringBuilder builder, Bindings bindings) { - throw new UnsupportedOperationException("TODO"); + throw new UnsupportedOperationException( + "appendStructure not implemented in " + getClass().getSimpleName() + ); } protected String elementsToString() { @@ -51,5 +53,4 @@ protected String elementsToString() { public String toString() { return String.format("[%s]", elementsToString()); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstMacroFunction.java b/src/main/java/com/hubspot/jinjava/el/ext/AstMacroFunction.java index b55de47c7..044249407 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstMacroFunction.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstMacroFunction.java @@ -1,40 +1,164 @@ package com.hubspot.jinjava.el.ext; -import java.lang.reflect.InvocationTargetException; - -import javax.el.ELContext; -import javax.el.ELException; - +import com.google.common.collect.ImmutableMap; +import com.hubspot.algebra.Result; +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.CallStack; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; import com.hubspot.jinjava.lib.fn.MacroFunction; - import de.odysseus.el.misc.LocalMessages; import de.odysseus.el.tree.Bindings; import de.odysseus.el.tree.impl.ast.AstFunction; import de.odysseus.el.tree.impl.ast.AstParameters; +import java.lang.reflect.InvocationTargetException; +import javax.el.ELContext; +import javax.el.ELException; public class AstMacroFunction extends AstFunction { + public enum MacroCallError { + CYCLE_DETECTED, + } + public AstMacroFunction(String name, int index, AstParameters params, boolean varargs) { super(name, index, params, varargs); } @Override public Object eval(Bindings bindings, ELContext context) { - JinjavaInterpreter interpreter = (JinjavaInterpreter) context.getELResolver().getValue(context, null, ExtendedParser.INTERPRETER); + JinjavaInterpreter interpreter = ((HasInterpreter) context).interpreter(); MacroFunction macroFunction = interpreter.getContext().getGlobalMacro(getName()); if (macroFunction != null) { - try { - return super.invoke(bindings, context, macroFunction, AbstractCallableMethod.EVAL_METHOD); - } catch (IllegalAccessException e) { - throw new ELException(LocalMessages.get("error.function.access", getName()), e); - } catch (InvocationTargetException e) { - throw new ELException(LocalMessages.get("error.function.invocation", getName()), e.getCause()); + if (macroFunction.isDeferred()) { + throw new DeferredValueException( + getName(), + interpreter.getLineNumber(), + interpreter.getPosition() + ); + } + if (macroFunction.isCaller()) { + return wrapInvoke(bindings, context, macroFunction); + } + try ( + AutoCloseableImpl> macroStackPush = + checkAndPushMacroStackWithWrapper(interpreter, getName()).get() + ) { + return macroStackPush + .value() + .match(err -> "", path -> wrapInvoke(bindings, context, macroFunction)); } } - return super.eval(bindings, context); + return interpreter.getContext().isValidationMode() + ? "" + : super.eval(bindings, context); } + private Object wrapInvoke( + Bindings bindings, + ELContext context, + MacroFunction macroFunction + ) { + try { + return invoke(bindings, context, macroFunction, AbstractCallableMethod.EVAL_METHOD); + } catch (IllegalAccessException e) { + throw new ELException(LocalMessages.get("error.function.access", getName()), e); + } catch (InvocationTargetException e) { + throw new ELException( + LocalMessages.get("error.function.invocation", getName()), + e.getCause() + ); + } + } + + public static AutoCloseableSupplier> checkAndPushMacroStackWithWrapper( + JinjavaInterpreter interpreter, + String name + ) { + CallStack macroStack = interpreter.getContext().getMacroStack(); + if (interpreter.getConfig().isEnableRecursiveMacroCalls()) { + if (interpreter.getConfig().getMaxMacroRecursionDepth() != 0) { + return macroStack + .closeablePushWithMaxDepth( + name, + interpreter.getConfig().getMaxMacroRecursionDepth(), + interpreter.getLineNumber(), + interpreter.getPosition() + ) + .map(result -> + result.mapErr(err -> { + handleMacroCycleError(interpreter, name, err); + return MacroCallError.CYCLE_DETECTED; + }) + ); + } else { + return macroStack + .closeablePushWithoutCycleCheck( + name, + interpreter.getLineNumber(), + interpreter.getPosition() + ) + .map(Result::ok); + } + } + return macroStack + .closeablePush(name, -1, -1) + .map(result -> + result.mapErr(err -> { + handleMacroCycleError(interpreter, name, err); + return MacroCallError.CYCLE_DETECTED; + }) + ); + } + + private static void handleMacroCycleError( + JinjavaInterpreter interpreter, + String name, + TagCycleException e + ) { + int maxDepth = interpreter.getConfig().getMaxMacroRecursionDepth(); + if (maxDepth != 0 && interpreter.getConfig().isValidationMode()) { + // validation mode is only concerned with syntax + return; + } + + String message = maxDepth == 0 + ? String.format("Cycle detected for macro '%s'", name) + : String.format("Max recursion limit of %d reached for macro '%s'", maxDepth, name); + + interpreter.addError( + new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.EXCEPTION, + TemplateError.ErrorItem.TAG, + message, + null, + e.getLineNumber(), + e.getStartPosition(), + e, + BasicTemplateErrorCategory.CYCLE_DETECTED, + ImmutableMap.of("name", name) + ) + ); + } + + @Deprecated + public static boolean checkAndPushMacroStack( + JinjavaInterpreter interpreter, + String name + ) { + return checkAndPushMacroStackWithWrapper(interpreter, name) + .dangerouslyGetWithoutClosing() + .match( + err -> true, // cycle detected + ok -> false // no cycle + ); + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstNamedParameter.java b/src/main/java/com/hubspot/jinjava/el/ext/AstNamedParameter.java index f70036003..2a78251eb 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstNamedParameter.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstNamedParameter.java @@ -1,16 +1,15 @@ package com.hubspot.jinjava.el.ext; -import javax.el.ELContext; - import de.odysseus.el.tree.Bindings; import de.odysseus.el.tree.impl.ast.AstIdentifier; import de.odysseus.el.tree.impl.ast.AstLiteral; import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; public class AstNamedParameter extends AstLiteral { - private AstIdentifier name; - private AstNode value; + private final AstIdentifier name; + private final AstNode value; public AstNamedParameter(AstIdentifier name, AstNode value) { this.name = name; @@ -24,7 +23,8 @@ public Object eval(Bindings bindings, ELContext context) { @Override public void appendStructure(StringBuilder builder, Bindings bindings) { - throw new UnsupportedOperationException("TODO"); + throw new UnsupportedOperationException( + "appendStructure not implemented in " + getClass().getSimpleName() + ); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstRangeBracket.java b/src/main/java/com/hubspot/jinjava/el/ext/AstRangeBracket.java index 228a2e7a0..97eccfee2 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstRangeBracket.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstRangeBracket.java @@ -1,26 +1,34 @@ package com.hubspot.jinjava.el.ext; +import com.google.common.collect.Iterables; +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyList; +import de.odysseus.el.misc.LocalMessages; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstBracket; +import de.odysseus.el.tree.impl.ast.AstNode; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; - import javax.el.ELContext; import javax.el.ELException; import javax.el.PropertyNotFoundException; -import com.hubspot.jinjava.objects.collections.PyList; - -import de.odysseus.el.misc.LocalMessages; -import de.odysseus.el.tree.Bindings; -import de.odysseus.el.tree.impl.ast.AstBracket; -import de.odysseus.el.tree.impl.ast.AstNode; - public class AstRangeBracket extends AstBracket { protected final AstNode rangeMax; - public AstRangeBracket(AstNode base, AstNode rangeStart, AstNode rangeMax, boolean lvalue, boolean strict, boolean ignoreReturnType) { + public AstRangeBracket( + AstNode base, + AstNode rangeStart, + AstNode rangeMax, + boolean lvalue, + boolean strict, + boolean ignoreReturnType + ) { super(base, rangeStart, lvalue, strict, ignoreReturnType); this.rangeMax = rangeMax; } @@ -29,13 +37,29 @@ public AstRangeBracket(AstNode base, AstNode rangeStart, AstNode rangeMax, boole public Object eval(Bindings bindings, ELContext context) { Object base = prefix.eval(bindings, context); if (base == null) { - throw new PropertyNotFoundException(LocalMessages.get("error.property.base.null", prefix)); + throw new PropertyNotFoundException( + LocalMessages.get("error.property.base.null", prefix) + ); } - if (!Iterable.class.isAssignableFrom(base.getClass()) && !base.getClass().isArray()) { + boolean baseIsString = base.getClass().equals(String.class); + if ( + !Iterable.class.isAssignableFrom(base.getClass()) && + !base.getClass().isArray() && + !baseIsString + ) { throw new ELException("Property " + prefix + " is not a sequence."); } - Object start = property.eval(bindings, context); + // https://github.com/HubSpot/jinjava/issues/52 + if (baseIsString) { + return evalString((String) base, bindings, context); + } + + Iterable baseItr = base.getClass().isArray() + ? Arrays.asList((Object[]) base) + : (Iterable) base; + + Object start = property == null ? 0 : property.eval(bindings, context); if (start == null && strict) { return Collections.emptyList(); } @@ -43,7 +67,9 @@ public Object eval(Bindings bindings, ELContext context) { throw new ELException("Range start is not a number"); } - Object end = rangeMax.eval(bindings, context); + Object end = rangeMax == null + ? (Iterables.size(baseItr)) + : rangeMax.eval(bindings, context); if (end == null && strict) { return Collections.emptyList(); } @@ -51,20 +77,29 @@ public Object eval(Bindings bindings, ELContext context) { throw new ELException("Range end is not a number"); } - Iterable baseItr; - - if (base.getClass().isArray()) { - baseItr = Arrays.asList((Object[]) base); - } - else { - baseItr = (Iterable) base; - } - - PyList result = new PyList(new ArrayList<>()); int startNum = ((Number) start).intValue(); int endNum = ((Number) end).intValue(); + + JinjavaInterpreter interpreter = ((HasInterpreter) context).interpreter(); + + PyList result = new SizeLimitingPyList( + new ArrayList<>(), + interpreter.getConfig().getMaxListSize() + ); int index = 0; + // Handle negative indices. + if ((startNum < 0) || (endNum < 0)) { + // size may have been calculated already + int size = rangeMax == null ? endNum : Iterables.size(baseItr); + if (startNum < 0) { + startNum += size; + } + if (endNum < 0) { + endNum += size; + } + } + Iterator baseIterator = baseItr.iterator(); while (baseIterator.hasNext()) { Object next = baseIterator.next(); @@ -81,9 +116,43 @@ public Object eval(Bindings bindings, ELContext context) { return result; } + private String evalString(String base, Bindings bindings, ELContext context) { + if (base.length() == 0) { + return base; + } + int startNum = intVal(property, 0, base.length(), bindings, context); + int endNum = intVal(rangeMax, base.length(), base.length(), bindings, context); + endNum = Math.min(endNum, base.length()); + + if (startNum > endNum) { + return ""; + } + return base.substring(startNum, endNum); + } + + private int intVal( + AstNode node, + int defVal, + int baseLength, + Bindings bindings, + ELContext context + ) { + if (node == null) { + return defVal; + } + Object val = node.eval(bindings, context); + if (val == null) { + return defVal; + } + if (!(val instanceof Number)) { + throw new ELException("Range start/end is not a number"); + } + int result = ((Number) val).intValue(); + return result >= 0 ? result : baseLength + result; + } + @Override public String toString() { return "[:]"; } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstTuple.java b/src/main/java/com/hubspot/jinjava/el/ext/AstTuple.java index 5fb1478c7..e1c0bd221 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstTuple.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstTuple.java @@ -1,13 +1,10 @@ package com.hubspot.jinjava.el.ext; -import java.util.Collections; - -import javax.el.ELContext; - import com.hubspot.jinjava.objects.collections.PyList; - import de.odysseus.el.tree.Bindings; import de.odysseus.el.tree.impl.ast.AstParameters; +import java.util.Collections; +import javax.el.ELContext; public class AstTuple extends AstList { @@ -25,5 +22,4 @@ public Object eval(Bindings bindings, ELContext context) { public String toString() { return String.format("(%s)", elementsToString()); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/BannedAllowlistOptions.java b/src/main/java/com/hubspot/jinjava/el/ext/BannedAllowlistOptions.java new file mode 100644 index 000000000..6949f4933 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/BannedAllowlistOptions.java @@ -0,0 +1,64 @@ +package com.hubspot.jinjava.el.ext; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableSet; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +public class BannedAllowlistOptions { + + // These aren't required, but they prevent someone from misconfiguring Jinjava to allow sandbox bypass unintentionally + private static final String JAVA_LANG_REFLECT_PACKAGE = + Method.class.getPackage().getName(); // java.lang.reflect + private static final String JACKSON_DATABIND_PACKAGE = + ObjectMapper.class.getPackage().getName(); // com.fasterxml.jackson.databind + + private static final String[] BANNED_PREFIXES = { + Class.class.getCanonicalName(), + Object.class.getCanonicalName(), + JAVA_LANG_REFLECT_PACKAGE, + JACKSON_DATABIND_PACKAGE, + }; + + private static final Set ALLOWED_JINJAVA_PREFIXES = Stream + .concat( + Stream.of("com.hubspot.jinjava.testobjects."), + Arrays + .stream(AllowlistGroup.values()) + .flatMap(g -> + Stream + .of( + g.allowedDeclaredMethodsFromCanonicalClassPrefixes(), + g.allowedReturnTypeCanonicalClassPrefixes(), + g.allowedDeclaredMethodsFromClasses(), + g.allowedReturnTypeClasses() + ) + .flatMap(Arrays::stream) + ) + ) + .collect(ImmutableSet.toImmutableSet()); + + public static List findBannedPrefixes(Stream prefixes) { + return prefixes + .filter(prefixOrName -> + Arrays + .stream(BANNED_PREFIXES) + .anyMatch(banned -> + banned.startsWith(prefixOrName) || prefixOrName.startsWith(banned) + ) || + isIllegalJinjavaClass(prefixOrName) + ) + .toList(); + } + + private static boolean isIllegalJinjavaClass(String prefixOrName) { + if (!prefixOrName.startsWith("com.hubspot.jinjava")) { + return false; + } + // e.g. com.hubspot.jinjava.lib.exptest is allowed, but com.hubspot.jinjava.Jinjava will not be + return ALLOWED_JINJAVA_PREFIXES.stream().noneMatch(prefixOrName::startsWith); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java b/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java new file mode 100644 index 000000000..9210ca86d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java @@ -0,0 +1,736 @@ +/* + * Copyright 2006-2009 Odysseus Software GmbH + * Modifications Copyright (c) 2023 HubSpot 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.hubspot.jinjava.el.ext; + +import java.beans.FeatureDescriptor; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.el.CompositeELResolver; +import javax.el.ELContext; +import javax.el.ELException; +import javax.el.ELResolver; +import javax.el.ExpressionFactory; +import javax.el.MethodNotFoundException; +import javax.el.PropertyNotFoundException; +import javax.el.PropertyNotWritableException; + +/** + * Defines property resolution behavior on objects using the JavaBeans component architecture. This + * resolver handles base objects of any type, as long as the base is not null. It accepts any object + * as a property, and coerces it to a string. That string is then used to find a JavaBeans compliant + * property on the base object. The value is accessed using JavaBeans getters and setters. This + * resolver can be constructed in read-only mode, which means that isReadOnly will always return + * true and {@link #setValue(ELContext, Object, Object, Object)} will always throw + * PropertyNotWritableException. ELResolvers are combined together using {@link CompositeELResolver} + * s, to define rich semantics for evaluating an expression. See the javadocs for {@link ELResolver} + * for details. Because this resolver handles base objects of any type, it should be placed near the + * end of a composite resolver. Otherwise, it will claim to have resolved a property before any + * resolvers that come after it get a chance to test if they can do so as well. + * + * @see CompositeELResolver + * @see ELResolver + */ +public class BeanELResolver extends ELResolver { + + private static PropertyNotFoundException propertyNotFoundException = + new PropertyNotFoundException("Could not find property"); + + protected static final class BeanProperties { + + private final Map map = new HashMap(); + + public BeanProperties(Class baseClass) { + PropertyDescriptor[] descriptors; + try { + descriptors = Introspector.getBeanInfo(baseClass).getPropertyDescriptors(); + } catch (IntrospectionException e) { + throw new ELException(e); + } + for (PropertyDescriptor descriptor : descriptors) { + map.put(descriptor.getName(), new BeanProperty(descriptor)); + } + } + + public BeanProperty getBeanProperty(String property) { + return map.get(property); + } + } + + protected static final class BeanProperty { + + private final PropertyDescriptor descriptor; + + private Method readMethod; + private Method writedMethod; + + public BeanProperty(PropertyDescriptor descriptor) { + this.descriptor = descriptor; + } + + public Class getPropertyType() { + return descriptor.getPropertyType(); + } + + public Method getReadMethod() { + if (readMethod == null) { + readMethod = findAccessibleMethod(descriptor.getReadMethod()); + } + return readMethod; + } + + public Method getWriteMethod() { + if (writedMethod == null) { + writedMethod = findAccessibleMethod(descriptor.getWriteMethod()); + } + return writedMethod; + } + + public boolean isReadOnly() { + return getWriteMethod() == null; + } + } + + private static Method findPublicAccessibleMethod(Method method) { + if (method == null || !Modifier.isPublic(method.getModifiers())) { + return null; + } + if ( + method.isAccessible() || + Modifier.isPublic(method.getDeclaringClass().getModifiers()) + ) { + return method; + } + for (Class cls : method.getDeclaringClass().getInterfaces()) { + Method mth = null; + try { + mth = + findPublicAccessibleMethod( + cls.getMethod(method.getName(), method.getParameterTypes()) + ); + if (mth != null) { + return mth; + } + } catch (NoSuchMethodException ignore) { + // do nothing + } + } + Class cls = method.getDeclaringClass().getSuperclass(); + if (cls != null) { + Method mth = null; + try { + mth = + findPublicAccessibleMethod( + cls.getMethod(method.getName(), method.getParameterTypes()) + ); + if (mth != null) { + return mth; + } + } catch (NoSuchMethodException ignore) { + // do nothing + } + } + return null; + } + + // Changed modifier to protected + protected static Method findAccessibleMethod(Method method) { + Method result = findPublicAccessibleMethod(method); + if (result == null && method != null && Modifier.isPublic(method.getModifiers())) { + result = method; + try { + method.setAccessible(true); + } catch (SecurityException e) { + result = null; + } + } + return result; + } + + private final boolean readOnly; + private final ConcurrentHashMap, BeanProperties> cache; + + private ExpressionFactory defaultFactory; + + /** + * Creates a new read/write BeanELResolver. + */ + public BeanELResolver() { + this(false); + } + + /** + * Creates a new BeanELResolver whose read-only status is determined by the given parameter. + */ + public BeanELResolver(boolean readOnly) { + this.readOnly = readOnly; + this.cache = new ConcurrentHashMap, BeanProperties>(); + } + + /** + * If the base object is not null, returns the most general type that this resolver accepts for + * the property argument. Otherwise, returns null. Assuming the base is not null, this method + * will always return Object.class. This is because any object is accepted as a key and is + * coerced into a string. + * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @return null if base is null; otherwise Object.class. + */ + @Override + public Class getCommonPropertyType(ELContext context, Object base) { + return isResolvable(base) ? Object.class : null; + } + + /** + * If the base object is not null, returns an Iterator containing the set of JavaBeans + * properties available on the given object. Otherwise, returns null. The Iterator returned must + * contain zero or more instances of java.beans.FeatureDescriptor. Each info object contains + * information about a property in the bean, as obtained by calling the + * BeanInfo.getPropertyDescriptors method. The FeatureDescriptor is initialized using the same + * fields as are present in the PropertyDescriptor, with the additional required named + * attributes "type" and "resolvableAtDesignTime" set as follows: + *
    + *
  • {@link ELResolver#TYPE} - The runtime type of the property, from + * PropertyDescriptor.getPropertyType().
  • + *
  • {@link ELResolver#RESOLVABLE_AT_DESIGN_TIME} - true.
  • + *
+ * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @return An Iterator containing zero or more FeatureDescriptor objects, each representing a + * property on this bean, or null if the base object is null. + */ + @Override + public Iterator getFeatureDescriptors( + ELContext context, + Object base + ) { + if (isResolvable(base)) { + final PropertyDescriptor[] properties; + try { + properties = Introspector.getBeanInfo(base.getClass()).getPropertyDescriptors(); + } catch (IntrospectionException e) { + return Collections.emptyList().iterator(); + } + return new Iterator() { + int next = 0; + + public boolean hasNext() { + return properties != null && next < properties.length; + } + + public FeatureDescriptor next() { + PropertyDescriptor property = properties[next++]; + FeatureDescriptor feature = new FeatureDescriptor(); + feature.setDisplayName(property.getDisplayName()); + feature.setName(property.getName()); + feature.setShortDescription(property.getShortDescription()); + feature.setExpert(property.isExpert()); + feature.setHidden(property.isHidden()); + feature.setPreferred(property.isPreferred()); + feature.setValue(TYPE, property.getPropertyType()); + feature.setValue(RESOLVABLE_AT_DESIGN_TIME, true); + return feature; + } + + public void remove() { + throw new UnsupportedOperationException("cannot remove"); + } + }; + } + return null; + } + + /** + * If the base object is not null, returns the most general acceptable type that can be set on + * this bean property. If the base is not null, the propertyResolved property of the ELContext + * object must be set to true by this resolver, before returning. If this property is not true + * after this method is called, the caller should ignore the return value. The provided property + * will first be coerced to a String. If there is a BeanInfoProperty for this property and there + * were no errors retrieving it, the propertyType of the propertyDescriptor is returned. + * Otherwise, a PropertyNotFoundException is thrown. + * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @return If the propertyResolved property of ELContext was set to true, then the most general + * acceptable type; otherwise undefined. + * @throws NullPointerException + * if context is null + * @throws PropertyNotFoundException + * if base is not null and the specified property does not exist or is not readable. + * @throws ELException + * if an exception was thrown while performing the property or variable resolution. + * The thrown exception must be included as the cause property of this exception, if + * available. + */ + @Override + public Class getType(ELContext context, Object base, Object property) { + if (context == null) { + throw new NullPointerException(); + } + Class result = null; + if (isResolvable(base)) { + result = toBeanProperty(base, property).getPropertyType(); + context.setPropertyResolved(true); + } + return result; + } + + /** + * If the base object is not null, returns the current value of the given property on this bean. + * If the base is not null, the propertyResolved property of the ELContext object must be set to + * true by this resolver, before returning. If this property is not true after this method is + * called, the caller should ignore the return value. The provided property name will first be + * coerced to a String. If the property is a readable property of the base object, as per the + * JavaBeans specification, then return the result of the getter call. If the getter throws an + * exception, it is propagated to the caller. If the property is not found or is not readable, a + * PropertyNotFoundException is thrown. + * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @return If the propertyResolved property of ELContext was set to true, then the value of the + * given property. Otherwise, undefined. + * @throws NullPointerException + * if context is null + * @throws PropertyNotFoundException + * if base is not null and the specified property does not exist or is not readable. + * @throws ELException + * if an exception was thrown while performing the property or variable resolution. + * The thrown exception must be included as the cause property of this exception, if + * available. + */ + @Override + public Object getValue(ELContext context, Object base, Object property) { + if (context == null) { + throw new NullPointerException(); + } + Object result = null; + if (isResolvable(base)) { + Method method = getReadMethod(base, property); + if (method == null) { + throw new PropertyNotFoundException("Cannot read property " + property); + } + try { + result = method.invoke(base); + } catch (InvocationTargetException e) { + throw new ELException(e.getCause()); + } catch (Exception e) { + throw new ELException(e); + } + context.setPropertyResolved(true); + } + return result; + } + + /** + * If the base object is not null, returns whether a call to + * {@link #setValue(ELContext, Object, Object, Object)} will always fail. If the base is not + * null, the propertyResolved property of the ELContext object must be set to true by this + * resolver, before returning. If this property is not true after this method is called, the + * caller can safely assume no value was set. + * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @return If the propertyResolved property of ELContext was set to true, then true if calling + * the setValue method will always fail or false if it is possible that such a call may + * succeed; otherwise undefined. + * @throws NullPointerException + * if context is null + * @throws PropertyNotFoundException + * if base is not null and the specified property does not exist or is not readable. + * @throws ELException + * if an exception was thrown while performing the property or variable resolution. + * The thrown exception must be included as the cause property of this exception, if + * available. + */ + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + if (context == null) { + throw new NullPointerException(); + } + boolean result = readOnly; + if (isResolvable(base)) { + result |= toBeanProperty(base, property).isReadOnly(); + context.setPropertyResolved(true); + } + return result; + } + + /** + * If the base object is not null, attempts to set the value of the given property on this bean. + * If the base is not null, the propertyResolved property of the ELContext object must be set to + * true by this resolver, before returning. If this property is not true after this method is + * called, the caller can safely assume no value was set. If this resolver was constructed in + * read-only mode, this method will always throw PropertyNotWritableException. The provided + * property name will first be coerced to a String. If property is a writable property of base + * (as per the JavaBeans Specification), the setter method is called (passing value). If the + * property exists but does not have a setter, then a PropertyNotFoundException is thrown. If + * the property does not exist, a PropertyNotFoundException is thrown. + * + * @param context + * The context of this evaluation. + * @param base + * The bean to analyze. + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @param value + * The value to be associated with the specified key. + * @throws NullPointerException + * if context is null + * @throws PropertyNotFoundException + * if base is not null and the specified property does not exist or is not readable. + * @throws PropertyNotWritableException + * if this resolver was constructed in read-only mode, or if there is no setter for + * the property + * @throws ELException + * if an exception was thrown while performing the property or variable resolution. + * The thrown exception must be included as the cause property of this exception, if + * available. + */ + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + if (context == null) { + throw new NullPointerException(); + } + if (isResolvable(base)) { + if (readOnly) { + throw new PropertyNotWritableException("resolver is read-only"); + } + Method method = getWriteMethod(base, property); + if (method == null) { + throw new PropertyNotWritableException("Cannot write property: " + property); + } + try { + method.invoke(base, value); + } catch (InvocationTargetException e) { + throw new ELException("Cannot write property: " + property, e.getCause()); + } catch (IllegalArgumentException e) { + throw new ELException("Cannot write property: " + property, e); + } catch (IllegalAccessException e) { + throw new PropertyNotWritableException("Cannot write property: " + property, e); + } + context.setPropertyResolved(true); + } + } + + /** + * If the base object is not null, invoke the method, with the given parameters on + * this bean. The return value from the method is returned. + * + *

+ * If the base is not null, the propertyResolved property of the + * ELContext object must be set to true by this resolver, before + * returning. If this property is not true after this method is called, the caller + * should ignore the return value. + *

+ * + *

+ * The provided method object will first be coerced to a String. The methods in the + * bean is then examined and an attempt will be made to select one for invocation. If no + * suitable can be found, a MethodNotFoundException is thrown. + * + * If the given paramTypes is not null, select the method with the given name and + * parameter types. + * + * Else select the method with the given name that has the same number of parameters. If there + * are more than one such method, the method selection process is undefined. + * + * Else select the method with the given name that takes a variable number of arguments. + * + * Note the resolution for overloaded methods will likely be clarified in a future version of + * the spec. + * + * The provided parameters are coerced to the corresponding parameter types of the method, and + * the method is then invoked. + * + * @param context + * The context of this evaluation. + * @param base + * The bean on which to invoke the method + * @param method + * The simple name of the method to invoke. Will be coerced to a String. + * If method is "<init>"or "<clinit>" a MethodNotFoundException is + * thrown. + * @param paramTypes + * An array of Class objects identifying the method's formal parameter types, in + * declared order. Use an empty array if the method has no parameters. Can be + * null, in which case the method's formal parameter types are assumed + * to be unknown. + * @param params + * The parameters to pass to the method, or null if no parameters. + * @return The result of the method invocation (null if the method has a + * void return type). + * @throws MethodNotFoundException + * if no suitable method can be found. + * @throws ELException + * if an exception was thrown while performing (base, method) resolution. The thrown + * exception must be included as the cause property of this exception, if available. + * If the exception thrown is an InvocationTargetException, extract its + * cause and pass it to the ELException constructor. + * @since 2.2 + */ + @Override + public Object invoke( + ELContext context, + Object base, + Object method, + Class[] paramTypes, + Object[] params + ) { + if (context == null) { + throw new NullPointerException(); + } + Object result = null; + if (isResolvable(base)) { + if (params == null) { + params = new Object[0]; + } + String name = method.toString(); + Method target = findMethod(base, name, paramTypes, params, params.length); + if (target == null) { + throw new MethodNotFoundException( + "Cannot find method " + + name + + " with " + + params.length + + " parameters in " + + base.getClass() + ); + } + try { + result = + target.invoke( + base, + coerceParams(getExpressionFactory(context), target, params) + ); + } catch (InvocationTargetException e) { + throw new ELException(e.getCause()); + } catch (IllegalAccessException e) { + throw new ELException(e); + } + context.setPropertyResolved(true); + } + return result; + } + + // Changed modifier to protected; Added `Object[] params` parameter + protected Method findMethod( + Object base, + String name, + Class[] types, + Object[] params, + int paramCount + ) { + if (types != null) { + try { + return findAccessibleMethod(base.getClass().getMethod(name, types)); + } catch (NoSuchMethodException e) { + return null; + } + } + Method varArgsMethod = null; + for (Method method : base.getClass().getMethods()) { + if (method.getName().equals(name)) { + int formalParamCount = method.getParameterTypes().length; + if (method.isVarArgs() && paramCount >= formalParamCount - 1) { + varArgsMethod = method; + } else if (paramCount == formalParamCount) { + return findAccessibleMethod(method); + } + } + } + return varArgsMethod == null ? null : findAccessibleMethod(varArgsMethod); + } + + /** + * Lookup an expression factory used to coerce method parameters in context under key + * "javax.el.ExpressionFactory". + * If no expression factory can be found under that key, use a default instance created with + * {@link ExpressionFactory#newInstance()}. + * @param context + * The context of this evaluation. + * @return expression factory instance + */ + protected ExpressionFactory getExpressionFactory(ELContext context) { + Object obj = context.getContext(ExpressionFactory.class); + if (obj instanceof ExpressionFactory) { + return (ExpressionFactory) obj; + } + if (defaultFactory == null) { + defaultFactory = ExpressionFactory.newInstance(); + } + return defaultFactory; + } + + protected Object[] coerceParams( + ExpressionFactory factory, + Method method, + Object[] params + ) { + Class[] types = method.getParameterTypes(); + Object[] args = new Object[types.length]; + if (method.isVarArgs()) { + int varargIndex = types.length - 1; + if (params.length < varargIndex) { + throw new ELException("Bad argument count"); + } + for (int i = 0; i < varargIndex; i++) { + coerceValue(args, i, factory, params[i], types[i]); + } + Class varargType = types[varargIndex].getComponentType(); + int length = params.length - varargIndex; + Object array = null; + if (length == 1) { + Object source = params[varargIndex]; + if (source != null && source.getClass().isArray()) { + if (types[varargIndex].isInstance(source)) { // use source array as is + array = source; + } else { // coerce array elements + length = Array.getLength(source); + array = Array.newInstance(varargType, length); + for (int i = 0; i < length; i++) { + coerceValue(array, i, factory, Array.get(source, i), varargType); + } + } + } else { // single element array + array = Array.newInstance(varargType, 1); + coerceValue(array, 0, factory, source, varargType); + } + } else { + array = Array.newInstance(varargType, length); + for (int i = 0; i < length; i++) { + coerceValue(array, i, factory, params[varargIndex + i], varargType); + } + } + args[varargIndex] = array; + } else { + if (params.length != args.length) { + throw new ELException("Bad argument count"); + } + for (int i = 0; i < args.length; i++) { + coerceValue(args, i, factory, params[i], types[i]); + } + } + return args; + } + + protected Method getWriteMethod(Object base, Object property) { + return toBeanProperty(base, property).getWriteMethod(); + } + + protected Method getReadMethod(Object base, Object property) { + return toBeanProperty(base, property).getReadMethod(); + } + + protected void coerceValue( + Object array, + int index, + ExpressionFactory factory, + Object value, + Class type + ) { + if (value != null || type.isPrimitive()) { + Array.set(array, index, factory.coerceToType(value, type)); + } + } + + /** + * Test whether the given base should be resolved by this ELResolver. + * + * @param base + * The bean to analyze. + * @return base != null + */ + private boolean isResolvable(Object base) { + return base != null; + } + + /** + * Lookup BeanProperty for the given (base, property) pair. + * + * @param base + * The bean to analyze. + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @return The BeanProperty representing (base, property). + * @throws PropertyNotFoundException + * if no BeanProperty can be found. + */ + private BeanProperty toBeanProperty(Object base, Object property) { + BeanProperties beanProperties = cache.get(base.getClass()); + if (beanProperties == null) { + BeanProperties newBeanProperties = new BeanProperties(base.getClass()); + beanProperties = cache.putIfAbsent(base.getClass(), newBeanProperties); + if (beanProperties == null) { // put succeeded, use new value + beanProperties = newBeanProperties; + } + } + BeanProperty beanProperty = property == null + ? null + : beanProperties.getBeanProperty(property.toString()); + if (beanProperty == null) { + throw propertyNotFoundException; + } + return beanProperty; + } + + /** + * This method is not part of the API, though it can be used (reflectively) by clients of this + * class to remove entries from the cache when the beans are being unloaded. + * + * Note: this method is present in the reference implementation, so we're adding it here to ease + * migration. + * + * @param loader + * The classLoader used to load the beans. + */ + @SuppressWarnings("unused") + private void purgeBeanClasses(ClassLoader loader) { + Iterator> classes = cache.keySet().iterator(); + while (classes.hasNext()) { + if (loader == classes.next().getClassLoader()) { + classes.remove(); + } + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperator.java index 6d2644793..662197cfe 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperator.java @@ -1,10 +1,6 @@ package com.hubspot.jinjava.el.ext; -import java.util.Collection; -import java.util.Objects; - -import org.apache.commons.lang3.StringUtils; - +import com.hubspot.jinjava.el.ext.eager.EagerAstBinary; import de.odysseus.el.misc.TypeConverter; import de.odysseus.el.tree.impl.Parser.ExtensionHandler; import de.odysseus.el.tree.impl.Parser.ExtensionPoint; @@ -12,13 +8,20 @@ import de.odysseus.el.tree.impl.ast.AstBinary; import de.odysseus.el.tree.impl.ast.AstBinary.SimpleOperator; import de.odysseus.el.tree.impl.ast.AstNode; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import javax.el.ELException; +import org.apache.commons.lang3.StringUtils; public class CollectionMembershipOperator extends SimpleOperator { @Override - protected Object apply(TypeConverter converter, Object o1, Object o2) { + public Object apply(TypeConverter converter, Object o1, Object o2) { if (o2 == null) { - return false; + return Boolean.FALSE; } if (CharSequence.class.isAssignableFrom(o2.getClass())) { @@ -26,20 +29,74 @@ protected Object apply(TypeConverter converter, Object o1, Object o2) { } if (Collection.class.isAssignableFrom(o2.getClass())) { - return ((Collection) o2).contains(o1); + Collection collection = (Collection) o2; + + for (Object value : collection) { + if (value == null) { + if (o1 == null) { + return Boolean.TRUE; + } + } else { + try { + return collection.contains(converter.convert(o1, value.getClass())); + } catch (ELException e) { + return Boolean.FALSE; + } + } + } + } + + if (Map.class.isAssignableFrom(o2.getClass())) { + Map map = (Map) o2; + // An implementation of Map can override isEmpty() to false, but return empty keySet. + if (map.isEmpty() || map.keySet().isEmpty()) { + return Boolean.FALSE; + } + Iterator iterator = map.keySet().iterator(); + Object key = iterator.next(); + if (key == null) { + if (o1 == null) { + return Boolean.TRUE; + } else { + if (iterator.hasNext()) { + // Must be non-null this time. + key = iterator.next(); + } else { + return Boolean.FALSE; + } + } + } + try { + Class keyClass = key.getClass(); + return map.containsKey(converter.convert(o1, keyClass)); + } catch (ELException | NoSuchElementException e) { + return Boolean.FALSE; + } } - return false; + return Boolean.FALSE; + } + + @Override + public String toString() { + return TOKEN.getImage(); } - public static final CollectionMembershipOperator OP = new CollectionMembershipOperator(); + public static final CollectionMembershipOperator OP = + new CollectionMembershipOperator(); public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("in"); - public static final ExtensionHandler HANDLER = new ExtensionHandler(ExtensionPoint.CMP) { - @Override - public AstNode createAstNode(AstNode... children) { - return new AstBinary(children[0], children[1], OP); - } - }; + public static final ExtensionHandler HANDLER = getHandler(false); + public static final ExtensionHandler EAGER_HANDLER = getHandler(true); + private static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.CMP) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager + ? new EagerAstBinary(children[0], children[1], OP) + : new AstBinary(children[0], children[1], OP); + } + }; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperator.java new file mode 100644 index 000000000..cd7bb681f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperator.java @@ -0,0 +1,43 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.el.ext.eager.EagerAstBinary; +import de.odysseus.el.misc.TypeConverter; +import de.odysseus.el.tree.impl.Parser.ExtensionHandler; +import de.odysseus.el.tree.impl.Parser.ExtensionPoint; +import de.odysseus.el.tree.impl.Scanner; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstBinary.SimpleOperator; +import de.odysseus.el.tree.impl.ast.AstNode; + +public class CollectionNonMembershipOperator extends SimpleOperator { + + @Override + public Object apply(TypeConverter converter, Object o1, Object o2) { + return !(Boolean) IN_OP.apply(converter, o1, o2); + } + + @Override + public String toString() { + return TOKEN.getImage(); + } + + public static final CollectionNonMembershipOperator NOT_IN_OP = + new CollectionNonMembershipOperator(); + public static final CollectionMembershipOperator IN_OP = + new CollectionMembershipOperator(); + public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("not in"); + + public static final ExtensionHandler HANDLER = getHandler(false); + public static final ExtensionHandler EAGER_HANDLER = getHandler(true); + + private static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.CMP) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager + ? new EagerAstBinary(children[0], children[1], NOT_IN_OP) + : new AstBinary(children[0], children[1], NOT_IN_OP); + } + }; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/DeferredInvocationResolutionException.java b/src/main/java/com/hubspot/jinjava/el/ext/DeferredInvocationResolutionException.java new file mode 100644 index 000000000..4a31f6277 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/DeferredInvocationResolutionException.java @@ -0,0 +1,8 @@ +package com.hubspot.jinjava.el.ext; + +public class DeferredInvocationResolutionException extends DeferredParsingException { + + public DeferredInvocationResolutionException(String invocationResultString) { + super(DeferredInvocationResolutionException.class, invocationResultString); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/DeferredParsingException.java b/src/main/java/com/hubspot/jinjava/el/ext/DeferredParsingException.java new file mode 100644 index 000000000..b03d1e6e0 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/DeferredParsingException.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.interpret.DeferredValueException; + +public class DeferredParsingException extends DeferredValueException { + + private final String deferredEvalResult; + private final Object sourceNode; + private final IdentifierPreservationStrategy identifierPreservationStrategy; + + public DeferredParsingException(Object sourceNode, String deferredEvalResult) { + super( + String.format( + "%s could not be parsed more than: %s", + sourceNode.getClass(), + deferredEvalResult + ) + ); + this.deferredEvalResult = deferredEvalResult; + this.sourceNode = sourceNode; + this.identifierPreservationStrategy = IdentifierPreservationStrategy.RESOLVING; + } + + public DeferredParsingException( + Object sourceNode, + String deferredEvalResult, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + super( + String.format( + "%s could not be parsed more than: %s", + sourceNode.getClass(), + deferredEvalResult + ) + ); + this.deferredEvalResult = deferredEvalResult; + this.sourceNode = sourceNode; + this.identifierPreservationStrategy = identifierPreservationStrategy; + } + + public String getDeferredEvalResult() { + return deferredEvalResult; + } + + public Object getSourceNode() { + return sourceNode; + } + + public IdentifierPreservationStrategy getIdentifierPreservationStrategy() { + return identifierPreservationStrategy; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java index 38a941130..a743aa2e0 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java @@ -4,21 +4,27 @@ import static de.odysseus.el.tree.impl.Builder.Feature.NULL_PROPERTIES; import static de.odysseus.el.tree.impl.Scanner.Symbol.COLON; import static de.odysseus.el.tree.impl.Scanner.Symbol.COMMA; +import static de.odysseus.el.tree.impl.Scanner.Symbol.DOT; +import static de.odysseus.el.tree.impl.Scanner.Symbol.EQ; +import static de.odysseus.el.tree.impl.Scanner.Symbol.FALSE; +import static de.odysseus.el.tree.impl.Scanner.Symbol.GE; +import static de.odysseus.el.tree.impl.Scanner.Symbol.GT; import static de.odysseus.el.tree.impl.Scanner.Symbol.IDENTIFIER; import static de.odysseus.el.tree.impl.Scanner.Symbol.LBRACK; +import static de.odysseus.el.tree.impl.Scanner.Symbol.LE; import static de.odysseus.el.tree.impl.Scanner.Symbol.LPAREN; +import static de.odysseus.el.tree.impl.Scanner.Symbol.LT; +import static de.odysseus.el.tree.impl.Scanner.Symbol.NE; import static de.odysseus.el.tree.impl.Scanner.Symbol.QUESTION; import static de.odysseus.el.tree.impl.Scanner.Symbol.RBRACK; import static de.odysseus.el.tree.impl.Scanner.Symbol.RPAREN; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import static de.odysseus.el.tree.impl.Scanner.Symbol.TRUE; import com.google.common.collect.Lists; - +import com.google.common.collect.Sets; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import de.odysseus.el.tree.impl.Builder; import de.odysseus.el.tree.impl.Builder.Feature; import de.odysseus.el.tree.impl.Parser; @@ -35,6 +41,14 @@ import de.odysseus.el.tree.impl.ast.AstNull; import de.odysseus.el.tree.impl.ast.AstParameters; import de.odysseus.el.tree.impl.ast.AstProperty; +import de.odysseus.el.tree.impl.ast.AstRightValue; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.el.ELException; public class ExtendedParser extends Parser { @@ -46,31 +60,69 @@ public class ExtendedParser extends Parser { static final Scanner.ExtensionToken IS = new Scanner.ExtensionToken("is"); static final Token IF = new Scanner.Token(Symbol.QUESTION, "if"); static final Token ELSE = new Scanner.Token(Symbol.COLON, "else"); + static final Token PYTRUE = new Scanner.Token(Symbol.TRUE, "True"); + static final Token PYFALSE = new Scanner.Token(Symbol.FALSE, "False"); - static final Scanner.ExtensionToken LITERAL_DICT_START = new Scanner.ExtensionToken("{"); + static final Scanner.ExtensionToken LITERAL_DICT_START = new Scanner.ExtensionToken( + "{" + ); static final Scanner.ExtensionToken LITERAL_DICT_END = new Scanner.ExtensionToken("}"); + static final Scanner.ExtensionToken TRUNC_DIV = TruncDivOperator.TOKEN; + static final Scanner.ExtensionToken POWER_OF = PowerOfOperator.TOKEN; + + static final Set VALID_SYMBOLS_FOR_EXP_TEST = Sets.newHashSet( + IDENTIFIER, + EQ, + NE, + LT, + LE, + GT, + GE, + TRUE, + FALSE, + CollectionMembershipOperator.TOKEN.getSymbol() + ); + static { ExtendedScanner.addKeyToken(IF); ExtendedScanner.addKeyToken(ELSE); + ExtendedScanner.addKeyToken(PYTRUE); + ExtendedScanner.addKeyToken(PYFALSE); + + ExtendedScanner.addKeyToken(TruncDivOperator.TOKEN); + ExtendedScanner.addKeyToken(PowerOfOperator.TOKEN); + ExtendedScanner.addKeyToken(CollectionMembershipOperator.TOKEN); + ExtendedScanner.addKeyToken(CollectionNonMembershipOperator.TOKEN); } public ExtendedParser(Builder context, String input) { super(context, input); - putExtensionHandler(AbsOperator.TOKEN, AbsOperator.HANDLER); putExtensionHandler(NamedParameterOperator.TOKEN, NamedParameterOperator.HANDLER); putExtensionHandler(StringConcatOperator.TOKEN, StringConcatOperator.HANDLER); - - putExtensionHandler(CollectionMembershipOperator.TOKEN, CollectionMembershipOperator.HANDLER); - - putExtensionHandler(PIPE, new ExtensionHandler(ExtensionPoint.AND) { - @Override - public AstNode createAstNode(AstNode... children) { - throw new IllegalStateException("Pipe operator reached from AST parse"); + putExtensionHandler(TruncDivOperator.TOKEN, TruncDivOperator.HANDLER); + putExtensionHandler(PowerOfOperator.TOKEN, PowerOfOperator.HANDLER); + + putExtensionHandler( + CollectionMembershipOperator.TOKEN, + CollectionMembershipOperator.HANDLER + ); + putExtensionHandler( + CollectionNonMembershipOperator.TOKEN, + CollectionNonMembershipOperator.HANDLER + ); + + putExtensionHandler( + PIPE, + new ExtensionHandler(ExtensionPoint.AND) { + @Override + public AstNode createAstNode(AstNode... children) { + throw new ELException("Illegal use of '|' operator"); + } } - }); + ); putExtensionHandler(LITERAL_DICT_START, NULL_EXT_HANDLER); putExtensionHandler(LITERAL_DICT_END, NULL_EXT_HANDLER); @@ -93,8 +145,7 @@ protected AstNode expr(boolean required) throws ScanException, ParseException { consumeToken(COLON); AstNode b = expr(true); v = createAstChoice(v, a, b); - } - else { + } else { consumeToken(); AstNode cond = expr(true); AstNode elseNode = new AstNull(); @@ -119,17 +170,17 @@ protected AstNode or(boolean required) throws ScanException, ParseException { } while (true) { switch (getToken().getSymbol()) { - case OR: - consumeToken(); - v = createAstBinary(v, and(true), OrOperator.OP); - break; - case EXTENSION: - if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.OR) { - v = getExtensionHandler(consumeToken()).createAstNode(v, and(true)); + case OR: + consumeToken(); + v = createAstBinary(v, and(true), OrOperator.OP); break; - } - default: - return v; + case EXTENSION: + if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.OR) { + v = getExtensionHandler(consumeToken()).createAstNode(v, and(true)); + break; + } + default: + return v; } } } @@ -142,21 +193,21 @@ protected AstNode add(boolean required) throws ScanException, ParseException { } while (true) { switch (getToken().getSymbol()) { - case PLUS: - consumeToken(); - v = createAstBinary(v, mul(true), AdditionOperator.OP); - break; - case MINUS: - consumeToken(); - v = createAstBinary(v, mul(true), AstBinary.SUB); - break; - case EXTENSION: - if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.ADD) { - v = getExtensionHandler(consumeToken()).createAstNode(v, mul(true)); + case PLUS: + consumeToken(); + v = createAstBinary(v, mul(true), AdditionOperator.OP); break; - } - default: - return v; + case MINUS: + consumeToken(); + v = createAstBinary(v, mul(true), AstBinary.SUB); + break; + case EXTENSION: + if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.ADD) { + v = getExtensionHandler(consumeToken()).createAstNode(v, mul(true)); + break; + } + default: + return v; } } } @@ -166,12 +217,13 @@ protected AstParameters params() throws ScanException, ParseException { return params(LPAREN, RPAREN); } - protected AstParameters params(Symbol left, Symbol right) throws ScanException, ParseException { + protected AstParameters params(Symbol left, Symbol right) + throws ScanException, ParseException { consumeToken(left); List l = Collections.emptyList(); AstNode v = expr(false); if (v != null) { - l = new ArrayList(); + l = new ArrayList<>(); l.add(v); while (getToken().getSymbol() == COMMA) { consumeToken(); @@ -179,12 +231,12 @@ protected AstParameters params(Symbol left, Symbol right) throws ScanException, } } consumeToken(right); - return new AstParameters(l); + return createAstParameters(l); } protected AstDict dict() throws ScanException, ParseException { consumeToken(); - Map dict = new HashMap<>(); + Map dict = new LinkedHashMap<>(); AstNode k = expr(false); if (k != null) { @@ -206,10 +258,15 @@ protected AstDict dict() throws ScanException, ParseException { } } - if (!getToken().getImage().equals("}")) { + Scanner.Token nextToken = getToken(); + if (nextToken == null || !"}".equals(nextToken.getImage())) { fail("}"); } consumeToken(); + return createAstDict(dict); + } + + protected AstDict createAstDict(Map dict) { return new AstDict(dict); } @@ -222,37 +279,49 @@ protected AstFunction createAstFunction(String name, int index, AstParameters pa protected AstNode nonliteral() throws ScanException, ParseException { AstNode v = null; switch (getToken().getSymbol()) { - case IDENTIFIER: - String name = consumeToken().getImage(); - if (getToken().getSymbol() == COLON && lookahead(0).getSymbol() == IDENTIFIER && lookahead(1).getSymbol() == LPAREN) { // ns:f(...) - consumeToken(); - name += ":" + getToken().getImage(); - consumeToken(); - } - if (getToken().getSymbol() == LPAREN) { // function - v = function(name, params()); - } else { // identifier - v = identifier(name); - } - break; - case LPAREN: - int i = 0; - Symbol s; - do { - s = lookahead(i++).getSymbol(); - if (s == Symbol.COMMA) { - return new AstTuple(params()); + case IDENTIFIER: + String name = consumeToken().getImage(); + if (getToken().getSymbol() == COLON && getToken().getImage().equals(":")) { + Symbol lookahead = lookahead(0).getSymbol(); + if ( + isPossibleExpTest(lookahead) && + (lookahead(1).getSymbol() == LPAREN || (isPossibleExpTestOrFilter(name))) + ) { // ns:f(...) + consumeToken(); + name += ":" + getToken().getImage(); + consumeToken(); + } + } + if (getToken().getSymbol() == LPAREN) { // function + v = function(name, params()); + } else { // identifier + v = identifier(name); + } + break; + case LPAREN: + int i = 0; + Symbol s = lookahead(i++).getSymbol(); + int depth = 0; + while (s != Symbol.EOF && (depth > 0 || s != Symbol.RPAREN)) { + if (s == LPAREN || s == LBRACK) { + depth++; + } else if (depth > 0 && (s == RPAREN || s == RBRACK)) { + depth--; + } else if (depth == 0) { + if (s == Symbol.COMMA) { + return createAstTuple(params()); + } + } + s = lookahead(i++).getSymbol(); } - } while (s != Symbol.RPAREN && s != Symbol.EOF); - - consumeToken(); - v = expr(true); - consumeToken(RPAREN); - v = new AstNested(v); - break; - default: - break; + consumeToken(); + v = expr(true); + consumeToken(RPAREN); + v = createAstNested(v); + break; + default: + break; } return v; } @@ -261,22 +330,22 @@ protected AstNode nonliteral() throws ScanException, ParseException { protected AstNode literal() throws ScanException, ParseException { AstNode v = null; switch (getToken().getSymbol()) { - case LBRACK: - v = new AstList(params(LBRACK, RBRACK)); - break; - case LPAREN: - v = new AstTuple(params()); - break; - case EXTENSION: - if (getToken() == LITERAL_DICT_START) { - v = dict(); - } - else if (getToken() == LITERAL_DICT_END) { - return null; - } - break; - default: - break; + case LBRACK: + v = createAstList(params(LBRACK, RBRACK)); + + break; + case LPAREN: + v = createAstTuple(params()); + break; + case EXTENSION: + if (getToken() == LITERAL_DICT_START) { + v = dict(); + } else if (getToken() == LITERAL_DICT_END) { + return null; + } + break; + default: + break; } if (v != null) { @@ -286,8 +355,121 @@ else if (getToken() == LITERAL_DICT_END) { return super.literal(); } - protected AstRangeBracket createAstRangeBracket(AstNode base, AstNode rangeStart, AstNode rangeMax, boolean lvalue, boolean strict) { - return new AstRangeBracket(base, rangeStart, rangeMax, lvalue, strict, context.isEnabled(Feature.IGNORE_RETURN_TYPE)); + @Override + protected AstNode cmp(boolean required) throws ScanException, ParseException { + AstNode v = add(required); + if (v == null) { + return null; + } + while (true) { + switch (getToken().getSymbol()) { + case LT: + consumeToken(); + v = createAstBinary(v, add(true), AstBinary.LT); + break; + case LE: + consumeToken(); + v = createAstBinary(v, add(true), AstBinary.LE); + break; + case GE: + consumeToken(); + v = createAstBinary(v, add(true), AstBinary.GE); + break; + case GT: + consumeToken(); + v = createAstBinary(v, add(true), AstBinary.GT); + break; + case EXTENSION: + if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.CMP) { + v = getExtensionHandler(consumeToken()).createAstNode(v, add(true)); + break; + } + default: + if ( + "not".equals(getToken().getImage()) && "in".equals(lookahead(0).getImage()) + ) { + consumeToken(); // not + consumeToken(); // in + v = + getExtensionHandler(CollectionNonMembershipOperator.TOKEN) + .createAstNode(v, add(true)); + break; + } + } + return v; + } + } + + @Override + protected AstNode mul(boolean required) throws ScanException, ParseException { + ParseLevel next = shouldUseNaturalOperatorPrecedence() ? this::filter : this::unary; + + AstNode v = next.apply(required); + if (v == null) { + return null; + } + while (true) { + switch (getToken().getSymbol()) { + case MUL: + consumeToken(); + v = createAstBinary(v, next.apply(true), AstBinary.MUL); + break; + case DIV: + consumeToken(); + v = createAstBinary(v, next.apply(true), AstBinary.DIV); + break; + case MOD: + consumeToken(); + v = createAstBinary(v, next.apply(true), AstBinary.MOD); + break; + case EXTENSION: + if (getExtensionHandler(getToken()).getExtensionPoint() == ExtensionPoint.MUL) { + v = getExtensionHandler(consumeToken()).createAstNode(v, next.apply(true)); + break; + } + default: + return v; + } + } + } + + protected AstNode filter(boolean required) throws ScanException, ParseException { + AstNode v = unary(required); + if (v == null) { + return null; + } + return parseOperators(v); + } + + protected AstRightValue createAstNested(AstNode node) { + return new AstNested(node); + } + + protected AstTuple createAstTuple(AstParameters parameters) + throws ScanException, ParseException { + return new AstTuple(parameters); + } + + protected AstList createAstList(AstParameters parameters) + throws ScanException, ParseException { + return new AstList(parameters); + } + + protected AstRangeBracket createAstRangeBracket( + AstNode base, + AstNode rangeStart, + AstNode rangeMax, + boolean lvalue, + boolean strict + ) { + return new AstRangeBracket( + base, + rangeStart, + rangeMax, + lvalue, + strict, + context.isEnabled(Feature.IGNORE_RETURN_TYPE) + ); } @Override @@ -303,79 +485,182 @@ protected AstNode value() throws ScanException, ParseException { } while (true) { switch (getToken().getSymbol()) { - case DOT: - consumeToken(); - String name = consumeToken(IDENTIFIER).getImage(); - AstDot dot = createAstDot(v, name, lvalue); - if (getToken().getSymbol() == LPAREN && context.isEnabled(METHOD_INVOCATIONS)) { - v = createAstMethod(dot, params()); - } else { - v = dot; - } - break; - case LBRACK: - consumeToken(); - AstNode property = expr(true); - boolean strict = !context.isEnabled(NULL_PROPERTIES); - - Token nextToken = consumeToken(); - - if (nextToken.getSymbol() == COLON) { - AstNode rangeMax = expr(true); - consumeToken(RBRACK); - v = createAstRangeBracket(v, property, rangeMax, lvalue, strict); - } - else if (nextToken.getSymbol() == RBRACK) { - AstBracket bracket = createAstBracket(v, property, lvalue, strict); + case DOT: + consumeToken(); + String name = consumeToken(IDENTIFIER).getImage(); + AstDot dot = createAstDot(v, name, lvalue); if (getToken().getSymbol() == LPAREN && context.isEnabled(METHOD_INVOCATIONS)) { - v = createAstMethod(bracket, params()); + v = createAstMethod(dot, params()); } else { - v = bracket; + v = dot; } - } - else { - fail(RBRACK); - } - - break; - default: - if ("|".equals(getToken().getImage()) && lookahead(0).getSymbol() == IDENTIFIER) { - do { - consumeToken(); // '|' - String filterName = consumeToken().getImage(); - List filterParams = Lists.newArrayList(v, interpreter()); - - // optional filter args - if (getToken().getSymbol() == Symbol.LPAREN) { - AstParameters astParameters = params(); - for (int i = 0; i < astParameters.getCardinality(); i++) { - filterParams.add(astParameters.getChild(i)); - } + break; + case LBRACK: + consumeToken(); + AstNode property = expr(false); + boolean strict = !context.isEnabled(NULL_PROPERTIES); + + Token nextToken = consumeToken(); + + if (nextToken.getSymbol() == COLON) { + AstNode rangeMax = expr(false); + consumeToken(RBRACK); + v = createAstRangeBracket(v, property, rangeMax, lvalue, strict); + } else if (nextToken.getSymbol() == RBRACK) { + AstBracket bracket = createAstBracket(v, property, lvalue, strict); + if ( + getToken().getSymbol() == LPAREN && context.isEnabled(METHOD_INVOCATIONS) + ) { + v = createAstMethod(bracket, params()); + } else { + v = bracket; } + } else { + fail(RBRACK); + } - AstProperty filterProperty = createAstDot(identifier(FILTER_PREFIX + filterName), "filter", true); - v = createAstMethod(filterProperty, new AstParameters(filterParams)); // function("filter:" + filterName, new AstParameters(filterParams)); - - } while ("|".equals(getToken().getImage())); - } - else if ("is".equals(getToken().getImage()) && lookahead(0).getSymbol() == IDENTIFIER) { - consumeToken(); // 'is' - String exptestName = consumeToken().getImage(); - List exptestParams = Lists.newArrayList(v, interpreter()); - - // optional exptest arg - AstNode arg = expr(false); - if (arg != null) { - exptestParams.add(arg); + break; + default: + if (shouldUseNaturalOperatorPrecedence()) { + return v; } + return parseOperators(v); + } + } + } + + private AstNode parseOperators(AstNode left) throws ScanException, ParseException { + if ("|".equals(getToken().getImage()) && lookahead(0).getSymbol() == IDENTIFIER) { + if (shouldUseFilterChainOptimization()) { + return parseFiltersAsChain(left); + } else { + return parseFiltersAsNestedMethods(left); + } + } else if ( + "is".equals(getToken().getImage()) && + "not".equals(lookahead(0).getImage()) && + isPossibleExpTest(lookahead(1).getSymbol()) + ) { + consumeToken(); // 'is' + consumeToken(); // 'not' + return buildAstMethodForIdentifier(left, "evaluateNegated"); + } else if ( + "is".equals(getToken().getImage()) && isPossibleExpTest(lookahead(0).getSymbol()) + ) { + consumeToken(); // 'is' + return buildAstMethodForIdentifier(left, "evaluate"); + } + + return left; + } + + protected AstParameters createAstParameters(List nodes) { + return new AstParameters(nodes); + } + + protected AstFilterChain createAstFilterChain( + AstNode input, + List filterSpecs + ) { + return new AstFilterChain(input, filterSpecs); + } + + private AstNode parseFiltersAsChain(AstNode left) throws ScanException, ParseException { + List filterSpecs = new ArrayList<>(); + + do { + consumeToken(); // '|' + String filterName = consumeToken().getImage(); + AstParameters filterParams = null; + + // optional filter args + if (getToken().getSymbol() == Symbol.LPAREN) { + filterParams = params(); + } + + filterSpecs.add(new FilterSpec(filterName, filterParams)); + } while ("|".equals(getToken().getImage())); - AstProperty exptestProperty = createAstDot(identifier(EXPTEST_PREFIX + exptestName), "evaluate", true); - v = createAstMethod(exptestProperty, new AstParameters(exptestParams)); + return createAstFilterChain(left, filterSpecs); + } + + protected AstNode parseFiltersAsNestedMethods(AstNode left) + throws ScanException, ParseException { + AstNode v = left; + + do { + consumeToken(); // '|' + String filterName = consumeToken().getImage(); + List filterParams = Lists.newArrayList(v, interpreter()); + + // optional filter args + if (getToken().getSymbol() == Symbol.LPAREN) { + AstParameters astParameters = params(); + for (int i = 0; i < astParameters.getCardinality(); i++) { + filterParams.add(astParameters.getChild(i)); } + } + + AstProperty filterProperty = createAstDot( + identifier(FILTER_PREFIX + filterName), + "filter", + true + ); + v = createAstMethod(filterProperty, createAstParameters(filterParams)); + } while ("|".equals(getToken().getImage())); + + return v; + } + + protected boolean shouldUseFilterChainOptimization() { + return JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::isEnableFilterChainOptimization) + .orElse(false); + } - return v; + private boolean isPossibleExpTest(Symbol symbol) { + return VALID_SYMBOLS_FOR_EXP_TEST.contains(symbol); + } + + private boolean isPossibleExpTestOrFilter(String namespace) + throws ParseException, ScanException { + if ( + FILTER_PREFIX.substring(0, FILTER_PREFIX.length() - 1).equals(namespace) || + (EXPTEST_PREFIX.substring(0, EXPTEST_PREFIX.length() - 1).equals(namespace) && + lookahead(1).getSymbol() == DOT && + lookahead(2).getSymbol() == IDENTIFIER) + ) { + Token property = lookahead(2); + if ( + "filter".equals(property.getImage()) || + "evaluate".equals(property.getImage()) || + "evaluateNegated".equals(property.getImage()) + ) { // exptest:equalto.evaluate(... + return lookahead(3).getSymbol() == LPAREN; } } + return false; + } + + private AstNode buildAstMethodForIdentifier(AstNode astNode, String property) + throws ScanException, ParseException { + String exptestName = consumeToken().getImage(); + List exptestParams = Lists.newArrayList(astNode, interpreter()); + + // optional exptest arg + AstNode arg = value(); + if (arg != null) { + exptestParams.add(arg); + } + + AstProperty exptestProperty = createAstDot( + identifier(EXPTEST_PREFIX + exptestName), + property, + true + ); + return createAstMethod(exptestProperty, createAstParameters(exptestParams)); } @Override @@ -390,4 +675,17 @@ public AstNode createAstNode(AstNode... children) { } }; + private static boolean shouldUseNaturalOperatorPrecedence() { + return JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getLegacyOverrides) + .map(LegacyOverrides::isUseNaturalOperatorPrecedence) + .orElse(false); + } + + @FunctionalInterface + private interface ParseLevel { + AstNode apply(boolean required) throws ScanException, ParseException; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedScanner.java b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedScanner.java index e4af8df57..1116c275b 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedScanner.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedScanner.java @@ -1,13 +1,11 @@ package com.hubspot.jinjava.el.ext; +import de.odysseus.el.tree.impl.Scanner; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import com.google.common.base.Throwables; - -import de.odysseus.el.tree.impl.Scanner; - public class ExtendedScanner extends Scanner { protected ExtendedScanner(String input) { @@ -32,8 +30,7 @@ public Token next() throws ScanException { if (getPosition() == length) { token = fixed(Symbol.EOF); - } - else { + } else { token = nextToken(); } @@ -48,6 +45,7 @@ protected boolean isWhitespace(char c) { private static final Method ADD_KEY_TOKEN_METHOD; private static final Field TOKEN_FIELD; private static final Field POSITION_FIELD; + static { try { ADD_KEY_TOKEN_METHOD = Scanner.class.getDeclaredMethod("addKeyToken", Token.class); @@ -57,15 +55,21 @@ protected boolean isWhitespace(char c) { POSITION_FIELD = Scanner.class.getDeclaredField("position"); POSITION_FIELD.setAccessible(true); } catch (NoSuchFieldException | SecurityException | NoSuchMethodException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } + @SuppressFBWarnings( + value = "HSM_HIDING_METHOD", + justification = "Purposefully overriding to use static method instance of this class." + ) protected static void addKeyToken(Token token) { try { ADD_KEY_TOKEN_METHOD.invoke(null, token); - } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { - throw Throwables.propagate(e); + } catch ( + IllegalAccessException | IllegalArgumentException | InvocationTargetException e + ) { + throw new RuntimeException(e); } } @@ -73,15 +77,16 @@ protected void setToken(Token token) { try { TOKEN_FIELD.set(this, token); } catch (IllegalArgumentException | IllegalAccessException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } + @SuppressWarnings("boxing") protected void incrPosition(int n) { try { POSITION_FIELD.set(this, getPosition() + n); } catch (IllegalArgumentException | IllegalAccessException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } @@ -91,19 +96,23 @@ protected Token nextToken() throws ScanException { if (getInput().charAt(getPosition()) == '}') { if (getPosition() < getInput().length() - 1) { return ExtendedParser.LITERAL_DICT_END; - } - else { + } else { return fixed(Symbol.END_EVAL); } } return nextEval(); } else { - if (getPosition() + 1 < getInput().length() && getInput().charAt(getPosition() + 1) == '{') { + if ( + getPosition() + 1 < getInput().length() && + getInput().charAt(getPosition() + 1) == '{' + ) { switch (getInput().charAt(getPosition())) { - case '#': - return fixed(Symbol.START_EVAL_DEFERRED); - case '$': - return fixed(Symbol.START_EVAL_DYNAMIC); + case '#': + return fixed(Symbol.START_EVAL_DEFERRED); + case '$': + return fixed(Symbol.START_EVAL_DYNAMIC); + default: + return nextText(); } } return nextText(); @@ -113,8 +122,16 @@ protected Token nextToken() throws ScanException { @Override protected Token nextEval() throws ScanException { char c1 = getInput().charAt(getPosition()); - char c2 = getPosition() < getInput().length() - 1 ? getInput().charAt(getPosition() + 1) : (char) 0; + char c2 = getPosition() < getInput().length() - 1 + ? getInput().charAt(getPosition() + 1) + : (char) 0; + if (c1 == '/' && c2 == '/') { + return ExtendedParser.TRUNC_DIV; + } + if (c1 == '*' && c2 == '*') { + return ExtendedParser.POWER_OF; + } if (c1 == '|' && c2 != '|') { return ExtendedParser.PIPE; } @@ -151,29 +168,32 @@ protected Token nextString() throws ScanException { } else { c = getInput().charAt(i++); switch (c) { - case '\\': - case '\'': - case '"': - builder.append(c); - break; - - case 'n': - builder.append('\n'); - break; - case 't': - builder.append('\t'); - break; - case 'b': - builder.append('\b'); - break; - case 'f': - builder.append('\f'); - break; - case 'r': - builder.append('\r'); - break; - default: - throw new ScanException(getPosition(), "invalid escape sequence \\" + c, "\\" + quote + " or \\\\"); + case '\\': + case '\'': + case '"': + builder.append(c); + break; + case 'n': + builder.append('\n'); + break; + case 't': + builder.append('\t'); + break; + case 'b': + builder.append('\b'); + break; + case 'f': + builder.append('\f'); + break; + case 'r': + builder.append('\r'); + break; + default: + throw new ScanException( + getPosition(), + "invalid escape sequence \\" + c, + "\\" + quote + " or \\\\" + ); } } } else if (c == quote) { @@ -184,5 +204,4 @@ protected Token nextString() throws ScanException { } throw new ScanException(getPosition(), "unterminated string", String.valueOf(quote)); } - } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/FilterSpec.java b/src/main/java/com/hubspot/jinjava/el/ext/FilterSpec.java new file mode 100644 index 000000000..175016913 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/FilterSpec.java @@ -0,0 +1,48 @@ +package com.hubspot.jinjava.el.ext; + +import de.odysseus.el.tree.impl.ast.AstParameters; +import java.util.Objects; + +/** + * Specification for a filter in a filter chain. + * Holds the filter name and optional parameters. + */ +public class FilterSpec { + + private final String name; + private final AstParameters params; + + public FilterSpec(String name, AstParameters params) { + this.name = Objects.requireNonNull(name, "Filter name cannot be null"); + this.params = params; + } + + public String getName() { + return name; + } + + public AstParameters getParams() { + return params; + } + + public boolean hasParams() { + return params != null && params.getCardinality() > 0; + } + + @Override + public String toString() { + if (hasParams()) { + StringBuilder sb = new StringBuilder(name); + sb.append('('); + for (int i = 0; i < params.getCardinality(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(params.getChild(i)); + } + sb.append(')'); + return sb.toString(); + } + return name; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/IdentifierPreservationStrategy.java b/src/main/java/com/hubspot/jinjava/el/ext/IdentifierPreservationStrategy.java new file mode 100644 index 000000000..d3e6536dd --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/IdentifierPreservationStrategy.java @@ -0,0 +1,20 @@ +package com.hubspot.jinjava.el.ext; + +public enum IdentifierPreservationStrategy { + PRESERVING(true), + RESOLVING(false); + + public static IdentifierPreservationStrategy preserving(boolean preserveIdentifier) { + return preserveIdentifier ? PRESERVING : RESOLVING; + } + + private final boolean preserving; + + IdentifierPreservationStrategy(boolean preserving) { + this.preserving = preserving; + } + + public boolean isPreserving() { + return preserving; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolver.java b/src/main/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolver.java index 7f91621ed..dad811583 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolver.java @@ -1,72 +1,286 @@ package com.hubspot.jinjava.el.ext; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import javax.el.BeanELResolver; +import com.google.common.base.CaseFormat; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.MethodDescriptor; +import java.lang.invoke.MethodType; +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import javax.el.ELContext; +import javax.el.ELException; +import javax.el.ExpressionFactory; +import javax.el.MethodNotFoundException; /** * {@link BeanELResolver} supporting snake case property names. */ public class JinjavaBeanELResolver extends BeanELResolver { - /** - * Pattern to convert snake case to property names. - */ - private static final Pattern SNAKE_CASE = Pattern.compile("_([^_]?)"); - - /** - * Creates a new read/write {@link JinjavaBeanELResolver}. - */ - public JinjavaBeanELResolver() { - } - - /** - * Creates a new {@link JinjavaBeanELResolver} whose read-only status is determined by the given parameter. - */ - public JinjavaBeanELResolver(boolean readOnly) { - super(readOnly); - } - - @Override - public Class getType(ELContext context, Object base, Object property) { - return super.getType(context, base, transformPropertyName(property)); - } - - @Override - public Object getValue(ELContext context, Object base, Object property) { - return super.getValue(context, base, transformPropertyName(property)); - } - - @Override - public boolean isReadOnly(ELContext context, Object base, Object property) { - return super.isReadOnly(context, base, transformPropertyName(property)); - } - - @Override - public void setValue(ELContext context, Object base, Object property, Object value) { - super.setValue(context, base, transformPropertyName(property), value); - } - - /** - * Transform snake case to property name. - */ - private String transformPropertyName(Object property) { - if (property == null) { - return null; + + private static final Set DEFERRED_EXECUTION_RESTRICTED_METHODS = ImmutableSet + .builder() + .add("put") + .add("putAll") + .add("update") + .add("add") + .add("insert") + .add("pop") + .add("append") + .add("extend") + .add("clear") + .add("remove") + .add("addAll") + .add("removeAll") + .add("replace") + .add("replaceAll") + .add("putIfAbsent") + .add("sort") + .add("set") + .add("merge") + .build(); + + protected static final class BeanMethods { + + private final Map> map = new HashMap<>(); + + public BeanMethods(Class baseClass) { + MethodDescriptor[] descriptors; + try { + descriptors = Introspector.getBeanInfo(baseClass).getMethodDescriptors(); + } catch (IntrospectionException e) { + throw new ELException(e); + } + for (MethodDescriptor descriptor : descriptors) { + map.compute( + descriptor.getName(), + (k, v) -> { + if (v == null) { + v = new LinkedList<>(); + } + v.add(new BeanMethod(descriptor)); + return v; + } + ); + } + } + + public List getBeanMethods(String methodName) { + return map.get(methodName); + } + + protected static final class BeanMethod { + + private final MethodDescriptor descriptor; + + private volatile Method method; + + public BeanMethod(MethodDescriptor descriptor) { + this.descriptor = descriptor; + } + + public Method getMethod() { + if (method == null) { + method = findAccessibleMethod(descriptor.getMethod()); + } + return method; } + } + } + + private final ConcurrentHashMap, BeanMethods> beanMethodsCache; + + public JinjavaBeanELResolver() { + this(true); + } + + /** + * Creates a new read/write {@link JinjavaBeanELResolver}. + */ + public JinjavaBeanELResolver(boolean readOnly) { + super(readOnly); + this.beanMethodsCache = new ConcurrentHashMap, BeanMethods>(); + } + + @Override + public Class getType(ELContext context, Object base, Object property) { + return super.getType(context, base, transformPropertyName(property)); + } - String name = property.toString(); - Matcher m = SNAKE_CASE.matcher(name); + @Override + public Object getValue(ELContext context, Object base, Object property) { + return super.getValue(context, base, transformPropertyName(property)); + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + return super.isReadOnly(context, base, transformPropertyName(property)); + } + + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + super.setValue(context, base, transformPropertyName(property), value); + } + + @Override + public Object invoke( + ELContext context, + Object base, + Object method, + Class[] paramTypes, + Object[] params + ) { + if (method == null) { + throw new MethodNotFoundException("Cannot find method null in " + base.getClass()); + } + if ( + DEFERRED_EXECUTION_RESTRICTED_METHODS.contains(method.toString()) && + EagerReconstructionUtils.isDeferredExecutionMode() + ) { + throw new DeferredValueException( + String.format( + "Cannot run method '%s' in %s in deferred execution mode", + method, + base.getClass() + ) + ); + } + + return super.invoke(context, base, method, paramTypes, params); + } + + // As opposed to supporting ____int3rpr3t3r____, coerce JinjavaInterpreter on validated methods + @Override + protected void coerceValue( + Object array, + int index, + ExpressionFactory factory, + Object value, + Class type + ) { + if (type.equals(JinjavaInterpreter.class)) { + Array.set(array, index, JinjavaInterpreter.getCurrent()); // Could be null if there's no current interpreter + } else { + super.coerceValue(array, index, factory, value, type); + } + } + + @Override + protected Method findMethod( + Object base, + String name, + Class[] types, + Object[] params, + int paramCount + ) { + Method method; + if (types != null) { + method = super.findMethod(base, name, types, params, paramCount); + } else { + Method varArgsMethod = null; + BeanMethods beanMethods = beanMethodsCache.get(base.getClass()); + if (beanMethods == null) { + BeanMethods newBeanMethods = new BeanMethods(base.getClass()); + beanMethods = beanMethodsCache.putIfAbsent(base.getClass(), newBeanMethods); + if (beanMethods == null) { // put succeeded, use new value + beanMethods = newBeanMethods; + } + } + + List potentialMethods = new LinkedList<>(); + + List methodsForName = beanMethods.getBeanMethods(name); + if (methodsForName == null) { + methodsForName = List.of(); + } + for (BeanMethods.BeanMethod bm : methodsForName) { + Method m = bm.getMethod(); + int formalParamCount = m.getParameterTypes().length; + if (m.isVarArgs() && paramCount >= formalParamCount - 1) { + varArgsMethod = m; + } else if (paramCount == formalParamCount) { + potentialMethods.add(m); + } + } + final Method finalVarArgsMethod = varArgsMethod; + method = + potentialMethods + .stream() + .filter(m -> checkAssignableParameterTypes(params, m)) + .min(JinjavaBeanELResolver::pickMoreSpecificMethod) + .orElseGet(() -> potentialMethods.stream().findAny().orElse(finalVarArgsMethod) + ); + } + return getAllowlistMethodValidator().validateMethod(method); + } + + @Override + protected Method getWriteMethod(Object base, Object property) { + return getAllowlistMethodValidator() + .validateMethod(super.getWriteMethod(base, property)); + } + + @Override + protected Method getReadMethod(Object base, Object property) { + return getAllowlistMethodValidator() + .validateMethod(super.getReadMethod(base, property)); + } + + private static AllowlistMethodValidator getAllowlistMethodValidator() { + return JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getMethodValidator()) + .orElse(AllowlistMethodValidator.DEFAULT); + } + + private static boolean checkAssignableParameterTypes(Object[] params, Method method) { + for (int i = 0; i < method.getParameterTypes().length; i++) { + Class paramType = method.getParameterTypes()[i]; + if (paramType.isPrimitive()) { + paramType = MethodType.methodType(paramType).wrap().returnType(); + } + if (params[i] != null && !paramType.isAssignableFrom(params[i].getClass())) { + return false; + } + } + return true; + } - StringBuffer result = new StringBuffer(name.length()); - while (m.find()) { - String replacement = m.group(1).toUpperCase(); - m.appendReplacement(result, replacement); + private static int pickMoreSpecificMethod(Method methodA, Method methodB) { + Class[] typesA = methodA.getParameterTypes(); + Class[] typesB = methodB.getParameterTypes(); + for (int i = 0; i < typesA.length; i++) { + if (!typesA[i].isAssignableFrom(typesB[i])) { + if (typesB[i].isPrimitive()) { + return 1; + } + return -1; } - m.appendTail(result); + } + return 1; + } - return result.toString(); - } + /** + * Transform snake case to property name. + */ + private String transformPropertyName(Object property) { + if (property == null) { + return null; + } + String propertyStr = property.toString(); + if (propertyStr.indexOf('_') == -1) { + return propertyStr; + } + return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, propertyStr); + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/JinjavaListELResolver.java b/src/main/java/com/hubspot/jinjava/el/ext/JinjavaListELResolver.java index e413a6977..cea4ac484 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/JinjavaListELResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/JinjavaListELResolver.java @@ -1,5 +1,6 @@ package com.hubspot.jinjava.el.ext; +import java.util.List; import javax.el.ELContext; import javax.el.ListELResolver; @@ -30,18 +31,81 @@ public boolean isReadOnly(ELContext context, Object base, Object property) { @Override public Object getValue(ELContext context, Object base, Object property) { try { + // If we're dealing with a negative index, convert it to a positive one. + if (isResolvable(base)) { + int index = toIndex(property); + if (index < 0) { + // Leave the range checking to the superclass. + property = index + ((List) base).size(); + } + } return super.getValue(context, base, property); } catch (IllegalArgumentException e) { return null; } } + /** + * Copied from the unfortunately private ListELResolver.isResolvable + */ + private static boolean isResolvable(Object base) { + return base instanceof List; + } + + /** + * Convert the given property to an index. Inspired by + * ListELResolver.toIndex, but without the base param since we only use it for + * getValue where base is null. + * + * @param property + * The name of the property to analyze. Will be coerced to a String. + * @return The index of property in base. + * @throws IllegalArgumentException + * if property cannot be coerced to an integer. + */ + private static int toIndex(Object property) { + int index; + if (property instanceof Number) { + index = ((Number) property).intValue(); + } else if (property instanceof String) { + if (!isNumeric((String) property)) { + throw new IllegalArgumentException("Cannot parse list index: " + property); + } + try { + // ListELResolver uses valueOf, but findbugs complains. + index = Integer.parseInt((String) property); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Cannot parse list index: " + property); + } + } else if (property instanceof Character) { + index = ((Character) property).charValue(); + } else if (property instanceof Boolean) { + index = ((Boolean) property).booleanValue() ? 1 : 0; + } else { + throw new IllegalArgumentException( + "Cannot coerce property to list index: " + property + ); + } + return index; + } + @Override public void setValue(ELContext context, Object base, Object property, Object value) { try { super.setValue(context, base, property, value); - } catch (IllegalArgumentException e) { - /* */ } + } catch (IllegalArgumentException ignored) {} } + public static boolean isNumeric(final CharSequence cs) { + if (cs == null || cs.length() == 0) { + return false; + } + final int sz = cs.length(); + for (int i = 0; i < sz; i++) { + if (!Character.isDigit(cs.charAt(i)) && cs.charAt(i) != '-') { + return false; + } + } + return true; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/MethodValidator.java b/src/main/java/com/hubspot/jinjava/el/ext/MethodValidator.java new file mode 100644 index 000000000..eb065b7d2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/MethodValidator.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.el.ext; + +import java.lang.reflect.Method; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public interface MethodValidator { + @Nullable + Method validateMethod(@Nonnull Method m); +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/MethodValidatorConfig.java b/src/main/java/com/hubspot/jinjava/el/ext/MethodValidatorConfig.java new file mode 100644 index 000000000..52ecea9e1 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/MethodValidatorConfig.java @@ -0,0 +1,77 @@ +package com.hubspot.jinjava.el.ext; + +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.JinjavaImmutableStyle; +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; +import org.immutables.value.Value; + +@Value.Immutable(singleton = true) +@JinjavaImmutableStyle +public abstract class MethodValidatorConfig { + + public abstract ImmutableSet allowedMethods(); + + public abstract ImmutableSet allowedDeclaredMethodsFromCanonicalClassPrefixes(); + + public abstract ImmutableSet allowedDeclaredMethodsFromCanonicalClassNames(); + + @Value.Default + public Consumer onRejectedMethod() { + return m -> {}; + } + + @Value.Check + void banClassesAndMethods() { + List list = BannedAllowlistOptions.findBannedPrefixes( + Stream + .of( + allowedMethods() + .stream() + .map(method -> method.getDeclaringClass().getCanonicalName()), + allowedDeclaredMethodsFromCanonicalClassPrefixes().stream(), + allowedDeclaredMethodsFromCanonicalClassNames().stream() + ) + .flatMap(Function.identity()) + ); + if (!list.isEmpty()) { + throw new IllegalStateException( + "Banned classes or prefixes (Object.class, Class.class, java.lang.reflect, com.fasterxml.jackson.databind) are not allowed: " + + list + ); + } + } + + public static MethodValidatorConfig of() { + return ImmutableMethodValidatorConfig.of(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ImmutableMethodValidatorConfig.Builder { + + Builder() {} + + public Builder addDefaultAllowlistGroups() { + return addAllowlistGroups(AllowlistGroup.values()); + } + + public Builder addAllowlistGroups(AllowlistGroup... allowlistGroups) { + for (AllowlistGroup allowlistGroup : allowlistGroups) { + this.addAllowedMethods(allowlistGroup.allowMethods()) + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + allowlistGroup.allowedDeclaredMethodsFromCanonicalClassPrefixes() + ) + .addAllowedDeclaredMethodsFromCanonicalClassNames( + allowlistGroup.allowedDeclaredMethodsFromClasses() + ); + } + return this; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/NamedParameter.java b/src/main/java/com/hubspot/jinjava/el/ext/NamedParameter.java index 1775f0a0b..b47ba1133 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/NamedParameter.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/NamedParameter.java @@ -1,8 +1,10 @@ package com.hubspot.jinjava.el.ext; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; import java.util.Objects; -public class NamedParameter { +public class NamedParameter implements PyishSerializable { private final String name; private final Object value; @@ -25,4 +27,13 @@ public String toString() { return Objects.toString(value, ""); } + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable + .append(name) + .append('=') + .append(PyishSerializable.writeValueAsString(value)); + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/NamedParameterOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/NamedParameterOperator.java index 04f7cc9a5..3d50953de 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/NamedParameterOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/NamedParameterOperator.java @@ -1,21 +1,31 @@ package com.hubspot.jinjava.el.ext; +import com.hubspot.jinjava.el.ext.eager.EagerAstNamedParameter; import de.odysseus.el.tree.impl.Parser.ExtensionHandler; import de.odysseus.el.tree.impl.Parser.ExtensionPoint; import de.odysseus.el.tree.impl.Scanner; import de.odysseus.el.tree.impl.ast.AstIdentifier; import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELException; public class NamedParameterOperator { public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("="); - public static final ExtensionHandler HANDLER = new ExtensionHandler(ExtensionPoint.ADD) { - @Override - public AstNode createAstNode(AstNode... children) { - AstIdentifier name = (AstIdentifier) children[0]; - return new AstNamedParameter(name, children[1]); - } - }; + public static final ExtensionHandler HANDLER = getHandler(false); + public static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.ADD) { + @Override + public AstNode createAstNode(AstNode... children) { + if (!(children[0] instanceof AstIdentifier)) { + throw new ELException("Expected IDENTIFIER, found " + children[0].toString()); + } + AstIdentifier name = (AstIdentifier) children[0]; + return eager + ? new EagerAstNamedParameter(name, children[1]) + : new AstNamedParameter(name, children[1]); + } + }; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/OrOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/OrOperator.java index a32f95c8b..71429eb43 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/OrOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/OrOperator.java @@ -1,10 +1,9 @@ package com.hubspot.jinjava.el.ext; -import javax.el.ELContext; - import de.odysseus.el.tree.Bindings; import de.odysseus.el.tree.impl.ast.AstBinary.Operator; import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; public class OrOperator implements Operator { @@ -18,5 +17,10 @@ public Object eval(Bindings bindings, ELContext context, AstNode left, AstNode r return right.eval(bindings, context); } + @Override + public String toString() { + return "||"; + } + public static final OrOperator OP = new OrOperator(); } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/PowerOfOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/PowerOfOperator.java new file mode 100644 index 000000000..1858e67c7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/PowerOfOperator.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.el.ext.eager.EagerAstBinary; +import de.odysseus.el.misc.TypeConverter; +import de.odysseus.el.tree.impl.Parser.ExtensionHandler; +import de.odysseus.el.tree.impl.Parser.ExtensionPoint; +import de.odysseus.el.tree.impl.Scanner; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstBinary.SimpleOperator; +import de.odysseus.el.tree.impl.ast.AstNode; + +public class PowerOfOperator extends SimpleOperator { + + public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("**"); + public static final PowerOfOperator OP = new PowerOfOperator(); + + @Override + protected Object apply(TypeConverter converter, Object a, Object b) { + boolean aInt = a instanceof Integer || a instanceof Long; + boolean bInt = b instanceof Integer || b instanceof Long; + boolean aNum = aInt || a instanceof Double || a instanceof Float; + boolean bNum = bInt || b instanceof Double || b instanceof Float; + + if (aInt && bInt) { + Long d = converter.convert(a, Long.class); + Long e = converter.convert(b, Long.class); + return (long) Math.pow(d, e); + } + if (aNum && bNum) { + Double d = converter.convert(a, Double.class); + Double e = converter.convert(b, Double.class); + return Math.pow(d, e); + } + throw new IllegalArgumentException( + String.format( + "Unsupported operand type(s) for **: '%s' (%s) and '%s' (%s)", + a, + (a == null ? "null" : a.getClass().getSimpleName()), + b, + (b == null ? "null" : b.getClass().getSimpleName()) + ) + ); + } + + @Override + public String toString() { + return TOKEN.getImage(); + } + + public static final ExtensionHandler HANDLER = getHandler(false); + + public static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.MUL) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager + ? new EagerAstBinary(children[0], children[1], OP) + : new AstBinary(children[0], children[1], OP); + } + }; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidator.java b/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidator.java new file mode 100644 index 000000000..f60f0fe2a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidator.java @@ -0,0 +1,8 @@ +package com.hubspot.jinjava.el.ext; + +import javax.annotation.Nullable; + +public interface ReturnTypeValidator { + @Nullable + Object validateReturnType(Object o); +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidatorConfig.java b/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidatorConfig.java new file mode 100644 index 000000000..8a885c519 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/ReturnTypeValidatorConfig.java @@ -0,0 +1,76 @@ +package com.hubspot.jinjava.el.ext; + +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.JinjavaImmutableStyle; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; +import org.immutables.value.Value; + +@Value.Immutable(singleton = true) +@JinjavaImmutableStyle +public abstract class ReturnTypeValidatorConfig { + + public abstract ImmutableSet allowedCanonicalClassPrefixes(); + + public abstract ImmutableSet allowedCanonicalClassNames(); + + @Value.Default + public Consumer> onRejectedClass() { + return m -> {}; + } + + @Value.Default + public boolean allowArrays() { + return false; + } + + @Value.Check + void banClassesAndMethods() { + List list = BannedAllowlistOptions.findBannedPrefixes( + Stream + .of( + allowedCanonicalClassPrefixes().stream(), + allowedCanonicalClassNames().stream() + ) + .flatMap(Function.identity()) + ); + if (!list.isEmpty()) { + throw new IllegalStateException( + "Banned classes or prefixes (Object.class, Class.class, java.lang.reflect, com.fasterxml.jackson.databind) are not allowed: " + + list + ); + } + } + + public static ReturnTypeValidatorConfig of() { + return ImmutableReturnTypeValidatorConfig.of(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ImmutableReturnTypeValidatorConfig.Builder { + + Builder() {} + + public Builder addDefaultAllowlistGroups() { + return addAllowlistGroups(AllowlistGroup.values()); + } + + public Builder addAllowlistGroups(AllowlistGroup... allowlistGroups) { + for (AllowlistGroup allowlistGroup : allowlistGroups) { + this.addAllowedCanonicalClassPrefixes( + allowlistGroup.allowedReturnTypeCanonicalClassPrefixes() + ) + .addAllowedCanonicalClassNames(allowlistGroup.allowedReturnTypeClasses()); + if (allowlistGroup.enableArrays()) { + this.setAllowArrays(true); + } + } + return this; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/StringBuildingOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/StringBuildingOperator.java new file mode 100644 index 000000000..47296089f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/StringBuildingOperator.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; + +public interface StringBuildingOperator { + default LengthLimitingStringBuilder getStringBuilder() { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + + long maxSize = (interpreter == null || interpreter.getConfig() == null) + ? 0 + : interpreter.getConfig().getMaxOutputSize(); + + return new LengthLimitingStringBuilder(maxSize); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/StringConcatOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/StringConcatOperator.java index ca5d9beef..16ddc66bc 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/StringConcatOperator.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/StringConcatOperator.java @@ -1,5 +1,6 @@ package com.hubspot.jinjava.el.ext; +import com.hubspot.jinjava.el.ext.eager.EagerAstBinary; import de.odysseus.el.misc.TypeConverter; import de.odysseus.el.tree.impl.Parser.ExtensionHandler; import de.odysseus.el.tree.impl.Parser.ExtensionPoint; @@ -8,24 +9,36 @@ import de.odysseus.el.tree.impl.ast.AstBinary.SimpleOperator; import de.odysseus.el.tree.impl.ast.AstNode; -public class StringConcatOperator extends SimpleOperator { +public class StringConcatOperator + extends SimpleOperator + implements StringBuildingOperator { @Override protected Object apply(TypeConverter converter, Object o1, Object o2) { String o1s = converter.convert(o1, String.class); String o2s = converter.convert(o2, String.class); - return new StringBuilder(o1s).append(o2s).toString(); + return getStringBuilder().append(o1s).append(o2s).toString(); + } + + @Override + public String toString() { + return TOKEN.getImage(); } public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("~"); public static final StringConcatOperator OP = new StringConcatOperator(); - public static final ExtensionHandler HANDLER = new ExtensionHandler(ExtensionPoint.ADD) { - @Override - public AstNode createAstNode(AstNode... children) { - return new AstBinary(children[0], children[1], OP); - } - }; + public static final ExtensionHandler HANDLER = getHandler(false); + public static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.ADD) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager + ? new EagerAstBinary(children[0], children[1], OP) + : new AstBinary(children[0], children[1], OP); + } + }; + } } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/TruncDivOperator.java b/src/main/java/com/hubspot/jinjava/el/ext/TruncDivOperator.java new file mode 100644 index 000000000..b28e4d0a1 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/TruncDivOperator.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.el.ext; + +import com.hubspot.jinjava.el.ext.eager.EagerAstBinary; +import de.odysseus.el.misc.TypeConverter; +import de.odysseus.el.tree.impl.Parser.ExtensionHandler; +import de.odysseus.el.tree.impl.Parser.ExtensionPoint; +import de.odysseus.el.tree.impl.Scanner; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstBinary.SimpleOperator; +import de.odysseus.el.tree.impl.ast.AstNode; + +public class TruncDivOperator extends SimpleOperator { + + public static final Scanner.ExtensionToken TOKEN = new Scanner.ExtensionToken("//"); + public static final TruncDivOperator OP = new TruncDivOperator(); + + @Override + protected Object apply(TypeConverter converter, Object a, Object b) { + boolean aInt = a instanceof Integer || a instanceof Long; + boolean bInt = b instanceof Integer || b instanceof Long; + boolean aNum = aInt || a instanceof Double || a instanceof Float; + boolean bNum = bInt || b instanceof Double || b instanceof Float; + + if (aInt && bInt) { + Long d = converter.convert(a, Long.class); + Long e = converter.convert(b, Long.class); + return Math.floorDiv(d, e); + } + if (aNum && bNum) { + Double d = converter.convert(a, Double.class); + Double e = converter.convert(b, Double.class); + return Math.floor(d / e); + } + throw new IllegalArgumentException( + String.format( + "Unsupported operand type(s) for //: '%s' (%s) and '%s' (%s)", + a, + (a == null ? "null" : a.getClass().getSimpleName()), + b, + (b == null ? "null" : b.getClass().getSimpleName()) + ) + ); + } + + @Override + public String toString() { + return TOKEN.getImage(); + } + + public static final ExtensionHandler HANDLER = getHandler(false); + + public static ExtensionHandler getHandler(boolean eager) { + return new ExtensionHandler(ExtensionPoint.MUL) { + @Override + public AstNode createAstNode(AstNode... children) { + return eager + ? new EagerAstBinary(children[0], children[1], OP) + : new AstBinary(children[0], children[1], OP); + } + }; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinary.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinary.java new file mode 100644 index 000000000..ea8c27ef1 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinary.java @@ -0,0 +1,91 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.NoInvokeELContext; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.el.ext.OrOperator; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; + +public class EagerAstBinary extends AstBinary implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final EvalResultHolder left; + protected final EvalResultHolder right; + protected final Operator operator; + + public EagerAstBinary(AstNode left, AstNode right, Operator operator) { + this( + EagerAstNodeDecorator.getAsEvalResultHolder(left), + EagerAstNodeDecorator.getAsEvalResultHolder(right), + operator + ); + } + + private EagerAstBinary( + EvalResultHolder left, + EvalResultHolder right, + Operator operator + ) { + super((AstNode) left, (AstNode) right, operator); + this.left = left; + this.right = right; + this.operator = operator; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return ( + EvalResultHolder.reconstructNode( + bindings, + context, + left, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + + String.format(" %s ", operator.toString()) + + EvalResultHolder.reconstructNode( + bindings, + (operator instanceof OrOperator || operator == AstBinary.AND) + ? new NoInvokeELContext(context) // short circuit on modification attempts because this may not be evaluated + : context, + right, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracket.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracket.java new file mode 100644 index 000000000..c02d71524 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracket.java @@ -0,0 +1,88 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstBracket; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; + +public class EagerAstBracket extends AstBracket implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstBracket( + AstNode base, + AstNode property, + boolean lvalue, + boolean strict, + boolean ignoreReturnType + ) { + super( + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(base), + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(property), + lvalue, + strict, + ignoreReturnType + ); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + public AstNode getPrefix() { + return prefix; + } + + public AstNode getMethod() { + return property; + } + + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return String.format( + "%s[%s]", + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) prefix, + deferredParsingException, + identifierPreservationStrategy + ), + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) property, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoice.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoice.java new file mode 100644 index 000000000..b4bbd43dc --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoice.java @@ -0,0 +1,110 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.NoInvokeELContext; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstChoice; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; +import javax.el.ELException; + +public class EagerAstChoice extends AstChoice implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final EvalResultHolder question; + protected final EvalResultHolder yes; + protected final EvalResultHolder no; + + public EagerAstChoice(AstNode question, AstNode yes, AstNode no) { + this( + EagerAstNodeDecorator.getAsEvalResultHolder(question), + EagerAstNodeDecorator.getAsEvalResultHolder(yes), + EagerAstNodeDecorator.getAsEvalResultHolder(no) + ); + } + + private EagerAstChoice( + EvalResultHolder question, + EvalResultHolder yes, + EvalResultHolder no + ) { + super((AstNode) question, (AstNode) yes, (AstNode) no); + this.question = question; + this.yes = yes; + this.no = no; + } + + @Override + public Object eval(Bindings bindings, ELContext context) throws ELException { + try { + setEvalResult(super.eval(bindings, context)); + return checkEvalResultSize(context); + } catch (DeferredParsingException e) { + if (question.hasEvalResult()) { + // the question was evaluated so jump to either yes or no + throw new DeferredParsingException(this, e.getDeferredEvalResult()); + } + throw new DeferredParsingException( + this, + getPartiallyResolved( + bindings, + context, + e, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return ( + EvalResultHolder.reconstructNode( + bindings, + context, + question, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + + " ? " + + EvalResultHolder.reconstructNode( + bindings, + new NoInvokeELContext(context), + yes, + deferredParsingException, + identifierPreservationStrategy + ) + + " : " + + EvalResultHolder.reconstructNode( + bindings, + new NoInvokeELContext(context), + no, + deferredParsingException, + identifierPreservationStrategy + ) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDict.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDict.java new file mode 100644 index 000000000..8bdff2434 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDict.java @@ -0,0 +1,106 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.el.ext.AstDict; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import de.odysseus.el.tree.impl.ast.AstNode; +import java.util.Map; +import java.util.StringJoiner; +import javax.el.ELContext; + +public class EagerAstDict extends AstDict implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstDict(Map dict) { + super(dict); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + JinjavaInterpreter interpreter = ((HasInterpreter) context).interpreter(); + StringJoiner joiner = new StringJoiner(", "); + dict.forEach((key, value) -> { + StringJoiner kvJoiner = new StringJoiner(": "); + if (key instanceof AstIdentifier) { + kvJoiner.add(((AstIdentifier) key).getName()); + } else if (key instanceof EvalResultHolder) { + kvJoiner.add( + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) key, + deferredParsingException, + IdentifierPreservationStrategy.preserving( + !interpreter.getConfig().getLegacyOverrides().isEvaluateMapKeys() + ) + ) + ); + } else { + kvJoiner.add( + EagerExpressionResolver.getValueAsJinjavaStringSafe(key.eval(bindings, context)) + ); + } + if (value instanceof EvalResultHolder) { + kvJoiner.add( + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) value, + deferredParsingException, + identifierPreservationStrategy + ) + ); + } else { + kvJoiner.add( + EagerExpressionResolver.getValueAsJinjavaStringSafe( + value.eval(bindings, context) + ) + ); + } + joiner.add(kvJoiner.toString()); + }); + String joined = joiner.toString(); + if (joined.endsWith("}")) { + // prevent 2 closing braces from being interpreted as a closing expression token + joined += ' '; + } + return String.format("{%s}", joined); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDot.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDot.java new file mode 100644 index 000000000..67638b1c5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstDot.java @@ -0,0 +1,95 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstDot; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; +import javax.el.ELException; + +public class EagerAstDot extends AstDot implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final EvalResultHolder base; + protected final String property; + + public EagerAstDot( + AstNode base, + String property, + boolean lvalue, + boolean ignoreReturnType + ) { + this( + EagerAstNodeDecorator.getAsEvalResultHolder(base), + property, + lvalue, + ignoreReturnType + ); + } + + public EagerAstDot( + EvalResultHolder base, + String property, + boolean lvalue, + boolean ignoreReturnType + ) { + super((AstNode) base, property, lvalue, ignoreReturnType); + this.base = base; + this.property = property; + } + + @Override + public Object eval(Bindings bindings, ELContext context) throws ELException { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException e, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return String.format( + "%s.%s", + EvalResultHolder.reconstructNode( + bindings, + context, + base, + e, + identifierPreservationStrategy + ), + property + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + public AstNode getPrefix() { + return prefix; + } + + public String getProperty() { + return property; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifier.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifier.java new file mode 100644 index 000000000..86ef61009 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifier.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import javax.el.ELContext; + +public class EagerAstIdentifier extends AstIdentifier implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstIdentifier(String name, int index, boolean ignoreReturnType) { + super(name, index, ignoreReturnType); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return getName(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstList.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstList.java new file mode 100644 index 000000000..fc1272366 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstList.java @@ -0,0 +1,66 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.AstList; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstParameters; +import java.util.StringJoiner; +import javax.el.ELContext; + +public class EagerAstList extends AstList implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstList(AstParameters elements) { + super(elements); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + StringJoiner joiner = new StringJoiner(", "); + for (int i = 0; i < elements.getCardinality(); i++) { + joiner.add( + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) elements.getChild(i), + deferredParsingException, + identifierPreservationStrategy + ) + ); + } + return "[" + joiner.toString() + "]"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMacroFunction.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMacroFunction.java new file mode 100644 index 000000000..85d2bb8fd --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMacroFunction.java @@ -0,0 +1,188 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.el.ext.AstMacroFunction; +import com.hubspot.jinjava.el.ext.DeferredInvocationResolutionException; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValueException; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstParameters; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import javax.el.ELContext; +import javax.el.ELException; + +public class EagerAstMacroFunction extends AstMacroFunction implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + // instanceof AstParameters + protected EvalResultHolder params; + protected boolean varargs; + + public EagerAstMacroFunction( + String name, + int index, + AstParameters params, + boolean varargs + ) { + this(name, index, EagerAstNodeDecorator.getAsEvalResultHolder(params), varargs); + } + + private EagerAstMacroFunction( + String name, + int index, + EvalResultHolder params, + boolean varargs + ) { + super(name, index, (AstParameters) params, varargs); + this.params = params; + this.varargs = varargs; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + try { + setEvalResult(super.eval(bindings, context)); + return checkEvalResultSize(context); + } catch (DeferredValueException | ELException originalException) { + DeferredParsingException e = EvalResultHolder.convertToDeferredParsingException( + originalException + ); + throw new DeferredParsingException( + this, + getPartiallyResolved( + bindings, + context, + e, + IdentifierPreservationStrategy.PRESERVING + ), // Need this to always be true because the macro function may modify the identifier + IdentifierPreservationStrategy.PRESERVING + ); + } + } + + @Override + protected Object invoke( + Bindings bindings, + ELContext context, + Object base, + Method method + ) throws InvocationTargetException, IllegalAccessException { + Class[] types = method.getParameterTypes(); + Object[] params = null; + if (types.length > 0) { + // This is just the AstFunction.invoke, but surrounded with this try-with-resources + try ( + TemporaryValueClosable c = + ((HasInterpreter) context).interpreter() + .getContext() + .withPartialMacroEvaluation(false) + ) { + params = new Object[types.length]; + int varargIndex; + Object param; + if (this.varargs && method.isVarArgs()) { + for (varargIndex = 0; varargIndex < params.length - 1; ++varargIndex) { + param = this.getParam(varargIndex).eval(bindings, context); + if (param != null || types[varargIndex].isPrimitive()) { + params[varargIndex] = bindings.convert(param, types[varargIndex]); + } + } + + varargIndex = types.length - 1; + Class varargType = types[varargIndex].getComponentType(); + int length = this.getParamCount() - varargIndex; + Object array = null; + if (length == 1) { + param = this.getParam(varargIndex).eval(bindings, context); + if (param != null && param.getClass().isArray()) { + if (types[varargIndex].isInstance(param)) { + array = param; + } else { + length = Array.getLength(param); + array = Array.newInstance(varargType, length); + + for (int i = 0; i < length; ++i) { + Object elem = Array.get(param, i); + if (elem != null || varargType.isPrimitive()) { + Array.set(array, i, bindings.convert(elem, varargType)); + } + } + } + } else { + array = Array.newInstance(varargType, 1); + if (param != null || varargType.isPrimitive()) { + Array.set(array, 0, bindings.convert(param, varargType)); + } + } + } else { + array = Array.newInstance(varargType, length); + + for (int i = 0; i < length; ++i) { + param = this.getParam(varargIndex + i).eval(bindings, context); + if (param != null || varargType.isPrimitive()) { + Array.set(array, i, bindings.convert(param, varargType)); + } + } + } + + params[varargIndex] = array; + } else { + for (varargIndex = 0; varargIndex < params.length; ++varargIndex) { + param = this.getParam(varargIndex).eval(bindings, context); + if (param != null || types[varargIndex].isPrimitive()) { + params[varargIndex] = bindings.convert(param, types[varargIndex]); + } + } + } + } + } + return method.invoke(base, params); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + if (deferredParsingException instanceof DeferredInvocationResolutionException) { + return deferredParsingException.getDeferredEvalResult(); + } + String paramString; + if (EvalResultHolder.exceptionMatchesNode(deferredParsingException, params)) { + paramString = deferredParsingException.getDeferredEvalResult(); + } else { + paramString = + params.getPartiallyResolved( + bindings, + context, + deferredParsingException, + identifierPreservationStrategy + ); + } + + return (getName() + String.format("(%s)", paramString)); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethod.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethod.java new file mode 100644 index 000000000..aec8fa234 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethod.java @@ -0,0 +1,111 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredInvocationResolutionException; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.interpret.DeferredValueException; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstMethod; +import de.odysseus.el.tree.impl.ast.AstParameters; +import de.odysseus.el.tree.impl.ast.AstProperty; +import javax.el.ELContext; +import javax.el.ELException; + +public class EagerAstMethod extends AstMethod implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + // instanceof AstProperty + protected final EvalResultHolder property; + // instanceof AstParameters + protected final EvalResultHolder params; + + public EagerAstMethod(AstProperty property, AstParameters params) { + this( + EagerAstNodeDecorator.getAsEvalResultHolder(property), + EagerAstNodeDecorator.getAsEvalResultHolder(params) + ); + } + + private EagerAstMethod(EvalResultHolder property, EvalResultHolder params) { + super((AstProperty) property, (AstParameters) params); + this.property = property; + this.params = params; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + try { + setEvalResult(super.eval(bindings, context)); + return checkEvalResultSize(context); + } catch (DeferredValueException | ELException originalException) { + DeferredParsingException e = EvalResultHolder.convertToDeferredParsingException( + originalException + ); + throw new DeferredParsingException( + this, + getPartiallyResolved( + bindings, + context, + e, + IdentifierPreservationStrategy.PRESERVING + ), // Need this to always be true because the method may modify the identifier + IdentifierPreservationStrategy.PRESERVING + ); + } + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + /** + * This method is used when we need to reconstruct the method property and params manually. + * Neither the property or params could be evaluated so we dive into the property and figure out + * where the DeferredParsingException came from. + */ + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + if (deferredParsingException instanceof DeferredInvocationResolutionException) { + return deferredParsingException.getDeferredEvalResult(); + } + String propertyResult; + propertyResult = + (property).getPartiallyResolved( + bindings, + context, + deferredParsingException, + identifierPreservationStrategy + ); + String paramString; + if (EvalResultHolder.exceptionMatchesNode(deferredParsingException, params)) { + paramString = deferredParsingException.getDeferredEvalResult(); + } else { + paramString = + params.getPartiallyResolved( + bindings, + context, + deferredParsingException, + identifierPreservationStrategy + ); + } + + return (propertyResult + String.format("(%s)", paramString)); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNamedParameter.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNamedParameter.java new file mode 100644 index 000000000..0cedd9839 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNamedParameter.java @@ -0,0 +1,74 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.AstNamedParameter; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; + +public class EagerAstNamedParameter + extends AstNamedParameter + implements EvalResultHolder { + + protected boolean hasEvalResult; + protected Object evalResult; + protected final AstIdentifier name; + protected final EvalResultHolder value; + + public EagerAstNamedParameter(AstIdentifier name, AstNode value) { + this(name, EagerAstNodeDecorator.getAsEvalResultHolder(value)); + } + + private EagerAstNamedParameter(AstIdentifier name, EvalResultHolder value) { + super(name, (AstNode) value); + this.name = name; + this.value = value; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return String.format( + "%s=%s", + name, + EvalResultHolder.reconstructNode( + bindings, + context, + value, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNested.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNested.java new file mode 100644 index 000000000..03d016175 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNested.java @@ -0,0 +1,89 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.Node; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstRightValue; +import javax.el.ELContext; + +/** + * AstNested is final so this decorates AstRightValue. + */ +public class EagerAstNested extends AstRightValue implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final AstNode child; + + public EagerAstNested(AstNode child) { + this.child = child; + } + + @Override + public void appendStructure(StringBuilder builder, Bindings bindings) { + builder.append("("); + child.appendStructure(builder, bindings); + builder.append(")"); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> child.eval(bindings, context), + bindings, + context + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public String toString() { + return "(...)"; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + @Override + public int getCardinality() { + return 1; + } + + @Override + public Node getChild(int i) { + return i == 0 ? child : null; + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return String.format( + "(%s)", + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) child, + deferredParsingException, + identifierPreservationStrategy + ) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNodeDecorator.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNodeDecorator.java new file mode 100644 index 000000000..8c978c506 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstNodeDecorator.java @@ -0,0 +1,143 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.Node; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; +import javax.el.MethodInfo; +import javax.el.ValueReference; + +/** + * This decorator exists to ensure that every EvalResultHolder is an + * instanceof AstNode. When using eager parsing, every AstNode should either + * be an EvalResultHolder or wrapped with this decorator. + */ +public class EagerAstNodeDecorator extends AstNode implements EvalResultHolder { + + private final AstNode astNode; + protected Object evalResult; + protected boolean hasEvalResult; + + public static EvalResultHolder getAsEvalResultHolder(AstNode astNode) { + if (astNode instanceof EvalResultHolder) { + return (EvalResultHolder) astNode; + } + if (astNode != null) { // Wraps nodes such as AstString, AstNumber + return new EagerAstNodeDecorator(astNode); + } + return null; + } + + private EagerAstNodeDecorator(AstNode astNode) { + this.astNode = astNode; + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } + + @Override + public void appendStructure(StringBuilder stringBuilder, Bindings bindings) { + astNode.appendStructure(stringBuilder, bindings); + } + + @Override + public Object eval(Bindings bindings, ELContext elContext) { + setEvalResult(astNode.eval(bindings, elContext)); + return checkEvalResultSize(elContext); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return astNode.toString(); + } + + @Override + public boolean isLiteralText() { + return astNode.isLiteralText(); + } + + @Override + public boolean isLeftValue() { + return astNode.isLeftValue(); + } + + @Override + public boolean isMethodInvocation() { + return astNode.isMethodInvocation(); + } + + @Override + public ValueReference getValueReference(Bindings bindings, ELContext elContext) { + return astNode.getValueReference(bindings, elContext); + } + + @Override + public Class getType(Bindings bindings, ELContext elContext) { + return astNode.getType(bindings, elContext); + } + + @Override + public boolean isReadOnly(Bindings bindings, ELContext elContext) { + return astNode.isReadOnly(bindings, elContext); + } + + @Override + public void setValue(Bindings bindings, ELContext elContext, Object o) { + astNode.setValue(bindings, elContext, o); + } + + @Override + public MethodInfo getMethodInfo( + Bindings bindings, + ELContext elContext, + Class aClass, + Class[] classes + ) { + return astNode.getMethodInfo(bindings, elContext, aClass, classes); + } + + @Override + public Object invoke( + Bindings bindings, + ELContext elContext, + Class aClass, + Class[] classes, + Object[] objects + ) { + if (hasEvalResult) { + return evalResult; + } + evalResult = astNode.invoke(bindings, elContext, aClass, classes, objects); + return evalResult; + } + + @Override + public int getCardinality() { + return astNode.getCardinality(); + } + + @Override + public Node getChild(int i) { + return astNode.getChild(i); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstParameters.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstParameters.java new file mode 100644 index 000000000..6cb9fc4ee --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstParameters.java @@ -0,0 +1,108 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValueException; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstParameters; +import java.util.List; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import javax.el.ELContext; +import javax.el.ELException; + +public class EagerAstParameters extends AstParameters implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final List nodes; + + public EagerAstParameters(List nodes) { + this( // to avoid converting nodes twice, call separate constructor + nodes + .stream() + .map(EagerAstNodeDecorator::getAsEvalResultHolder) + .map(e -> (AstNode) e) + .collect(Collectors.toList()), + true + ); + } + + private EagerAstParameters(List nodes, boolean convertedToEvalResultHolder) { + super(nodes); + this.nodes = nodes; + } + + @Override + public Object[] eval(Bindings bindings, ELContext context) { + try ( + TemporaryValueClosable c = + ((HasInterpreter) context).interpreter() + .getContext() + .withPartialMacroEvaluation(false) + ) { + try { + setEvalResult(super.eval(bindings, context)); + return (Object[]) checkEvalResultSize(context); + } catch (DeferredValueException | ELException originalException) { + DeferredParsingException e = EvalResultHolder.convertToDeferredParsingException( + originalException + ); + throw new DeferredParsingException( + this, + getPartiallyResolved( + bindings, + context, + e, + IdentifierPreservationStrategy.PRESERVING + ), // Need this to always be true because a function may modify the identifier + IdentifierPreservationStrategy.PRESERVING + ); + } + } + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + StringJoiner joiner = new StringJoiner(", "); + nodes + .stream() + .map(node -> (EvalResultHolder) node) + .forEach(node -> + joiner.add( + EvalResultHolder.reconstructNode( + bindings, + context, + node, + deferredParsingException, + identifierPreservationStrategy + ) + ) + ); + return joiner.toString(); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracket.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracket.java new file mode 100644 index 000000000..81771d3a4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracket.java @@ -0,0 +1,92 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.AstRangeBracket; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; + +public class EagerAstRangeBracket extends AstRangeBracket implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstRangeBracket( + AstNode base, + AstNode rangeStart, + AstNode rangeMax, + boolean lvalue, + boolean strict, + boolean ignoreReturnType + ) { + super( + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(base), + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(rangeStart), + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(rangeMax), + lvalue, + strict, + ignoreReturnType + ); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return ( + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) prefix, + deferredParsingException, + identifierPreservationStrategy + ) + + "[" + + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) property, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + + ":" + + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) rangeMax, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + + "]" + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRoot.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRoot.java new file mode 100644 index 000000000..1b8f1314a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstRoot.java @@ -0,0 +1,93 @@ +package com.hubspot.jinjava.el.ext.eager; + +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.Node; +import de.odysseus.el.tree.impl.ast.AstNode; +import javax.el.ELContext; +import javax.el.MethodInfo; +import javax.el.ValueReference; + +public class EagerAstRoot extends AstNode { + + private AstNode rootNode; + + public EagerAstRoot(AstNode rootNode) { + this.rootNode = rootNode; + } + + @Override + public void appendStructure(StringBuilder builder, Bindings bindings) { + rootNode.appendStructure(builder, bindings); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return rootNode.eval(bindings, context); + } + + @Override + public boolean isLiteralText() { + return rootNode.isLiteralText(); + } + + @Override + public boolean isLeftValue() { + return rootNode.isLeftValue(); + } + + @Override + public boolean isMethodInvocation() { + return rootNode.isMethodInvocation(); + } + + @Override + public ValueReference getValueReference(Bindings bindings, ELContext context) { + return rootNode.getValueReference(bindings, context); + } + + @Override + public Class getType(Bindings bindings, ELContext context) { + return rootNode.getType(bindings, context); + } + + @Override + public boolean isReadOnly(Bindings bindings, ELContext context) { + return rootNode.isReadOnly(bindings, context); + } + + @Override + public void setValue(Bindings bindings, ELContext context, Object value) { + rootNode.setValue(bindings, context, value); + } + + @Override + public MethodInfo getMethodInfo( + Bindings bindings, + ELContext context, + Class returnType, + Class[] paramTypes + ) { + return rootNode.getMethodInfo(bindings, context, returnType, paramTypes); + } + + @Override + public Object invoke( + Bindings bindings, + ELContext context, + Class returnType, + Class[] paramTypes, + Object[] paramValues + ) { + return rootNode.invoke(bindings, context, returnType, paramTypes, paramValues); + } + + @Override + public int getCardinality() { + return rootNode.getCardinality(); + } + + @Override + public Node getChild(int i) { + return rootNode.getChild(i); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstTuple.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstTuple.java new file mode 100644 index 000000000..46d6aa4d2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstTuple.java @@ -0,0 +1,66 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.AstTuple; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstParameters; +import java.util.StringJoiner; +import javax.el.ELContext; + +public class EagerAstTuple extends AstTuple implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + + public EagerAstTuple(AstParameters elements) { + super(elements); + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + StringJoiner joiner = new StringJoiner(", "); + for (int i = 0; i < elements.getCardinality(); i++) { + joiner.add( + EvalResultHolder.reconstructNode( + bindings, + context, + (EvalResultHolder) elements.getChild(i), + deferredParsingException, + identifierPreservationStrategy + ) + ); + } + return '(' + joiner.toString() + ')'; + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstUnary.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstUnary.java new file mode 100644 index 000000000..da38314b9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerAstUnary.java @@ -0,0 +1,70 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstUnary; +import javax.el.ELContext; + +public class EagerAstUnary extends AstUnary implements EvalResultHolder { + + protected Object evalResult; + protected boolean hasEvalResult; + protected final EvalResultHolder child; + protected final Operator operator; + + public EagerAstUnary(AstNode child, Operator operator) { + this(EagerAstNodeDecorator.getAsEvalResultHolder(child), operator); + } + + private EagerAstUnary(EvalResultHolder child, Operator operator) { + super((AstNode) child, operator); + this.child = child; + this.operator = operator; + } + + @Override + public Object eval(Bindings bindings, ELContext context) { + return EvalResultHolder.super.eval( + () -> super.eval(bindings, context), + bindings, + context + ); + } + + @Override + public String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ) { + return ( + operator.toString() + + EvalResultHolder.reconstructNode( + bindings, + context, + child, + deferredParsingException, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } + + @Override + public Object getEvalResult() { + return evalResult; + } + + @Override + public void setEvalResult(Object evalResult) { + this.evalResult = evalResult; + hasEvalResult = true; + } + + @Override + public boolean hasEvalResult() { + return hasEvalResult; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedParser.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedParser.java new file mode 100644 index 000000000..29f53e2b7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedParser.java @@ -0,0 +1,206 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static de.odysseus.el.tree.impl.Scanner.Symbol.END_EVAL; +import static de.odysseus.el.tree.impl.Scanner.Symbol.START_EVAL_DEFERRED; +import static de.odysseus.el.tree.impl.Scanner.Symbol.START_EVAL_DYNAMIC; + +import com.hubspot.jinjava.el.ext.AbsOperator; +import com.hubspot.jinjava.el.ext.AstDict; +import com.hubspot.jinjava.el.ext.AstList; +import com.hubspot.jinjava.el.ext.AstRangeBracket; +import com.hubspot.jinjava.el.ext.AstTuple; +import com.hubspot.jinjava.el.ext.CollectionMembershipOperator; +import com.hubspot.jinjava.el.ext.CollectionNonMembershipOperator; +import com.hubspot.jinjava.el.ext.ExtendedParser; +import com.hubspot.jinjava.el.ext.NamedParameterOperator; +import com.hubspot.jinjava.el.ext.PowerOfOperator; +import com.hubspot.jinjava.el.ext.StringConcatOperator; +import com.hubspot.jinjava.el.ext.TruncDivOperator; +import de.odysseus.el.tree.impl.Builder; +import de.odysseus.el.tree.impl.Builder.Feature; +import de.odysseus.el.tree.impl.Scanner.ScanException; +import de.odysseus.el.tree.impl.Scanner.Symbol; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstBinary.Operator; +import de.odysseus.el.tree.impl.ast.AstBracket; +import de.odysseus.el.tree.impl.ast.AstChoice; +import de.odysseus.el.tree.impl.ast.AstDot; +import de.odysseus.el.tree.impl.ast.AstEval; +import de.odysseus.el.tree.impl.ast.AstFunction; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import de.odysseus.el.tree.impl.ast.AstMethod; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstParameters; +import de.odysseus.el.tree.impl.ast.AstProperty; +import de.odysseus.el.tree.impl.ast.AstRightValue; +import de.odysseus.el.tree.impl.ast.AstUnary; +import java.util.List; +import java.util.Map; + +public class EagerExtendedParser extends ExtendedParser { + + public EagerExtendedParser(Builder context, String input) { + super(context, input); + putExtensionHandler(AbsOperator.TOKEN, AbsOperator.getHandler(true)); + putExtensionHandler( + NamedParameterOperator.TOKEN, + NamedParameterOperator.getHandler(true) + ); + putExtensionHandler( + StringConcatOperator.TOKEN, + StringConcatOperator.getHandler(true) + ); + putExtensionHandler(TruncDivOperator.TOKEN, TruncDivOperator.getHandler(true)); + putExtensionHandler(PowerOfOperator.TOKEN, PowerOfOperator.getHandler(true)); + + putExtensionHandler( + CollectionMembershipOperator.TOKEN, + CollectionMembershipOperator.EAGER_HANDLER + ); + + putExtensionHandler( + CollectionNonMembershipOperator.TOKEN, + CollectionNonMembershipOperator.EAGER_HANDLER + ); + } + + @Override + protected AstEval eval(boolean required, boolean deferred) + throws ScanException, ParseException { + AstEval v = null; + Symbol startEval = deferred ? START_EVAL_DEFERRED : START_EVAL_DYNAMIC; + if (getToken().getSymbol() == startEval) { + consumeToken(); + v = new AstEval(new EagerAstRoot(expr(true)), deferred); + consumeToken(END_EVAL); + } else if (required) { + fail(startEval); + } + return v; + } + + @Override + protected AstBinary createAstBinary(AstNode left, AstNode right, Operator operator) { + return new EagerAstBinary(left, right, operator); + } + + @Override + protected AstBracket createAstBracket( + AstNode base, + AstNode property, + boolean lvalue, + boolean strict + ) { + return new EagerAstBracket( + base, + property, + lvalue, + strict, + this.context.isEnabled(Feature.IGNORE_RETURN_TYPE) + ); + } + + @Override + protected AstFunction createAstFunction(String name, int index, AstParameters params) { + return new EagerAstMacroFunction( + name, + index, + params, + context.isEnabled(Feature.VARARGS) + ); + } + + @Override + protected AstChoice createAstChoice(AstNode question, AstNode yes, AstNode no) { + return new EagerAstChoice(question, yes, no); + } + + // @Override + // protected AstComposite createAstComposite(List nodes) { + // return new AstComposite(nodes); + // } + + @Override + protected AstDot createAstDot(AstNode base, String property, boolean lvalue) { + return new EagerAstDot( + base, + property, + lvalue, + this.context.isEnabled(Feature.IGNORE_RETURN_TYPE) + ); + } + + @Override + protected AstIdentifier createAstIdentifier(String name, int index) { + return new EagerAstIdentifier( + name, + index, + this.context.isEnabled(Feature.IGNORE_RETURN_TYPE) + ); + } + + @Override + protected AstMethod createAstMethod(AstProperty property, AstParameters params) { + return new EagerAstMethod(property, params); + } + + @Override + protected AstUnary createAstUnary( + AstNode child, + de.odysseus.el.tree.impl.ast.AstUnary.Operator operator + ) { + return new EagerAstUnary(child, operator); + } + + @Override + protected AstRangeBracket createAstRangeBracket( + AstNode base, + AstNode rangeStart, + AstNode rangeMax, + boolean lvalue, + boolean strict + ) { + return new EagerAstRangeBracket( + base, + rangeStart, + rangeMax, + lvalue, + strict, + context.isEnabled(Feature.IGNORE_RETURN_TYPE) + ); + } + + @Override + protected AstDict createAstDict(Map dict) { + return new EagerAstDict(dict); + } + + @Override + protected AstRightValue createAstNested(AstNode node) { + return new EagerAstNested( + (AstNode) EagerAstNodeDecorator.getAsEvalResultHolder(node) + ); + } + + @Override + protected AstTuple createAstTuple(AstParameters parameters) + throws ScanException, ParseException { + return new EagerAstTuple(parameters); + } + + @Override + protected AstList createAstList(AstParameters parameters) + throws ScanException, ParseException { + return new EagerAstList(parameters); + } + + @Override + protected AstParameters createAstParameters(List nodes) { + return new EagerAstParameters(nodes); + } + + @Override + protected boolean shouldUseFilterChainOptimization() { + return false; + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedSyntaxBuilder.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedSyntaxBuilder.java new file mode 100644 index 000000000..4226caab9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EagerExtendedSyntaxBuilder.java @@ -0,0 +1,20 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.ExtendedSyntaxBuilder; +import de.odysseus.el.tree.impl.Parser; + +public class EagerExtendedSyntaxBuilder extends ExtendedSyntaxBuilder { + + public EagerExtendedSyntaxBuilder() { + super(); + } + + public EagerExtendedSyntaxBuilder(Feature... features) { + super(features); + } + + @Override + protected Parser createParser(String expression) { + return new EagerExtendedParser(this, expression); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/EvalResultHolder.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/EvalResultHolder.java new file mode 100644 index 000000000..f58d05d38 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/EvalResultHolder.java @@ -0,0 +1,175 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.el.HasInterpreter; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.ExtendedParser; +import com.hubspot.jinjava.el.ext.IdentifierPreservationStrategy; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.interpret.PartiallyDeferredValue; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import de.odysseus.el.tree.Bindings; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import de.odysseus.el.tree.impl.ast.AstNode; +import java.util.Collection; +import java.util.function.Supplier; +import javax.el.ELContext; +import javax.el.ELException; + +public interface EvalResultHolder { + Object getEvalResult(); + + void setEvalResult(Object evalResult); + + boolean hasEvalResult(); + + default Object eval( + Supplier evalSupplier, + Bindings bindings, + ELContext context + ) { + try { + setEvalResult(evalSupplier.get()); + return checkEvalResultSize(context); + } catch (DeferredValueException | ELException originalException) { + DeferredParsingException e = EvalResultHolder.convertToDeferredParsingException( + originalException + ); + throw new DeferredParsingException( + this, + getPartiallyResolved( + bindings, + context, + e, + IdentifierPreservationStrategy.RESOLVING + ) + ); + } + } + + default Object checkEvalResultSize(ELContext context) { + Object evalResult = getEvalResult(); + if ( + evalResult instanceof Collection && + ((Collection) evalResult).size() > 100 && // TODO make size configurable + ((HasInterpreter) context).interpreter().getContext().isDeferLargeObjects() + ) { + throw new DeferredValueException("Collection too big"); + } + return evalResult; + } + + String getPartiallyResolved( + Bindings bindings, + ELContext context, + DeferredParsingException deferredParsingException, + IdentifierPreservationStrategy identifierPreservationStrategy + ); + + static String reconstructNode( + Bindings bindings, + ELContext context, + EvalResultHolder astNode, + DeferredParsingException exception, + IdentifierPreservationStrategy preserveIdentifier + ) { + if (astNode == null) { + return ""; + } + if ( + astNode instanceof AstIdentifier && + ExtendedParser.INTERPRETER.equals(((AstIdentifier) astNode).getName()) + ) { + return ExtendedParser.INTERPRETER; + } + preserveIdentifier = + IdentifierPreservationStrategy.preserving(preserveIdentifier.isPreserving()); + if ( + preserveIdentifier.isPreserving() && + !astNode.hasEvalResult() && + !(exceptionMatchesNode(exception, astNode)) + ) { + // Evaluate to determine if the result is primitive. If so, we don't need to preserve the identifier + try { + EagerExpressionResolver.getValueAsJinjavaStringSafe( + ((AstNode) astNode).eval(bindings, context) + ); + } catch (DeferredParsingException ignored) {} + } + Object evalResult = astNode.getEvalResult(); + if ( + exceptionMatchesNode(exception, astNode) && + exception.getIdentifierPreservationStrategy().isPreserving() + ) { + return exception.getDeferredEvalResult(); + } + if ( + !preserveIdentifier.isPreserving() || + (astNode.hasEvalResult() && + (EagerExpressionResolver.isPrimitive(evalResult) || + evalResult instanceof PartiallyDeferredValue)) + ) { + if (exceptionMatchesNode(exception, astNode)) { + return exception.getDeferredEvalResult(); + } + if (!astNode.hasEvalResult()) { + try { + evalResult = ((AstNode) astNode).eval(bindings, context); + } catch (DeferredParsingException e) { + return e.getDeferredEvalResult(); + } + } + try { + return EagerExpressionResolver.getValueAsJinjavaStringSafe(evalResult); + } catch (DeferredValueException e) { + if (astNode instanceof AstIdentifier) { + String name = ((AstIdentifier) astNode).getName(); + if ( + MetaContextVariables.isMetaContextVariable( + name, + ((HasInterpreter) context).interpreter().getContext() + ) + ) { + return name; + } + throw e; + } + } + } + return astNode.getPartiallyResolved( + bindings, + context, + exception, + IdentifierPreservationStrategy.PRESERVING + ); + } + + static DeferredParsingException convertToDeferredParsingException( + RuntimeException original + ) { + DeferredValueException deferredValueException; + if (!(original instanceof DeferredValueException)) { + if (original.getCause() instanceof DeferredValueException) { + deferredValueException = (DeferredValueException) original.getCause(); + } else { + throw original; + } + } else { + deferredValueException = (DeferredValueException) original; + } + if (deferredValueException instanceof DeferredParsingException) { + return (DeferredParsingException) deferredValueException; + } + return null; + } + + static boolean exceptionMatchesNode( + DeferredParsingException deferredParsingException, + Object astNode + ) { + return ( + deferredParsingException != null && + deferredParsingException.getSourceNode() == astNode + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/MacroFunctionTempVariable.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/MacroFunctionTempVariable.java new file mode 100644 index 000000000..32fe8bd43 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/MacroFunctionTempVariable.java @@ -0,0 +1,45 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.objects.serialization.PyishBlockSetSerializable; +import java.util.Objects; + +public class MacroFunctionTempVariable implements PyishBlockSetSerializable { + + private static final String CONTEXT_KEY_PREFIX = "__macro_%s_%d_temp_variable_%d__"; + private final String deferredResult; + + public MacroFunctionTempVariable(String deferredResult) { + this.deferredResult = deferredResult; + } + + public static String getVarName(String macroFunctionName, int hashCode, int callCount) { + return String.format( + CONTEXT_KEY_PREFIX, + macroFunctionName, + Math.abs(hashCode), + callCount + ); + } + + @Override + public String getBlockSetBody() { + return deferredResult; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MacroFunctionTempVariable that = (MacroFunctionTempVariable) o; + return deferredResult.equals(that.deferredResult); + } + + @Override + public int hashCode() { + return Objects.hash(deferredResult); + } +} diff --git a/src/main/java/com/hubspot/jinjava/el/ext/eager/RenderFlatTempVariable.java b/src/main/java/com/hubspot/jinjava/el/ext/eager/RenderFlatTempVariable.java new file mode 100644 index 000000000..682d3d5ca --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/ext/eager/RenderFlatTempVariable.java @@ -0,0 +1,40 @@ +package com.hubspot.jinjava.el.ext.eager; + +import com.hubspot.jinjava.objects.serialization.PyishBlockSetSerializable; +import java.util.Objects; + +public class RenderFlatTempVariable implements PyishBlockSetSerializable { + + private static final String CONTEXT_KEY_PREFIX = "__render_%d_temp_variable__"; + private final String deferredResult; + + public RenderFlatTempVariable(String deferredResult) { + this.deferredResult = deferredResult; + } + + public static String getVarName(String result) { + return String.format(CONTEXT_KEY_PREFIX, Math.abs(result.hashCode() >> 1)); + } + + @Override + public String getBlockSetBody() { + return deferredResult; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RenderFlatTempVariable that = (RenderFlatTempVariable) o; + return deferredResult.equals(that.deferredResult); + } + + @Override + public int hashCode() { + return Objects.hash(deferredResult); + } +} diff --git a/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java b/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java new file mode 100644 index 000000000..87b34a6b8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.features; + +public interface BuiltInFeatures { + String WHITESPACE_REQUIRED_WITHIN_TOKENS = "whitespace_required_within_tokens"; + String FIXED_DATE_TIME_FILTER_NULL_ARG = "FIXED_DATE_TIME_FILTER_NULL_ARG"; + String ECHO_UNDEFINED = "echoUndefined"; + String PREVENT_ACCIDENTAL_EXPRESSIONS = "PREVENT_ACCIDENTAL_EXPRESSIONS"; + String IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS = + "IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS"; + String OUTPUT_UNDEFINED_VARIABLES_ERROR = "OUTPUT_UNDEFINED_VARIABLES_ERROR"; + String INTEGER_SET_TO_LONG_CONVERSION = "INTEGER_SET_TO_LONG_CONVERSION"; +} diff --git a/src/main/java/com/hubspot/jinjava/features/DateTimeFeatureActivationStrategy.java b/src/main/java/com/hubspot/jinjava/features/DateTimeFeatureActivationStrategy.java new file mode 100644 index 000000000..c50e4f679 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/DateTimeFeatureActivationStrategy.java @@ -0,0 +1,31 @@ +package com.hubspot.jinjava.features; + +import com.hubspot.jinjava.interpret.Context; +import java.time.ZonedDateTime; + +public class DateTimeFeatureActivationStrategy implements FeatureActivationStrategy { + + private final ZonedDateTime activateAt; + + public static DateTimeFeatureActivationStrategy of(ZonedDateTime activateAt) { + return new DateTimeFeatureActivationStrategy(activateAt); + } + + private DateTimeFeatureActivationStrategy(ZonedDateTime activateAt) { + this.activateAt = activateAt; + } + + @Override + public boolean isActive(Context context) { + return ZonedDateTime.now().isAfter(activateAt); + } + + @Override + public boolean isActive() { + return false; // Not usable without context + } + + public ZonedDateTime getActivateAt() { + return activateAt; + } +} diff --git a/src/main/java/com/hubspot/jinjava/features/FeatureActivationStrategy.java b/src/main/java/com/hubspot/jinjava/features/FeatureActivationStrategy.java new file mode 100644 index 000000000..6ba27b206 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/FeatureActivationStrategy.java @@ -0,0 +1,11 @@ +package com.hubspot.jinjava.features; + +import com.hubspot.jinjava.interpret.Context; + +public interface FeatureActivationStrategy { + default boolean isActive(Context context) { + return isActive(); + } + + boolean isActive(); +} diff --git a/src/main/java/com/hubspot/jinjava/features/FeatureConfig.java b/src/main/java/com/hubspot/jinjava/features/FeatureConfig.java new file mode 100644 index 000000000..7038592ce --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/FeatureConfig.java @@ -0,0 +1,57 @@ +package com.hubspot.jinjava.features; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.interpret.Context; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class FeatureConfig { + + Map features; + + private FeatureConfig(Map features) { + this.features = ImmutableMap.copyOf(features); + } + + public FeatureActivationStrategy getFeature(String name) { + return features.getOrDefault(name, FeatureStrategies.INACTIVE); + } + + public static FeatureConfig.Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + + private final Map features = new HashMap<>(); + + @Deprecated + public Builder add(String name, Function strategyFunction) { + features.put( + name, + new FeatureActivationStrategy() { + @Override + public boolean isActive(Context context) { + return strategyFunction.apply(context); + } + + @Override + public boolean isActive() { + return false; + } + } + ); + return this; + } + + public Builder add(String name, FeatureActivationStrategy strategy) { + features.put(name, strategy); + return this; + } + + public FeatureConfig build() { + return new FeatureConfig(features); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/features/FeatureStrategies.java b/src/main/java/com/hubspot/jinjava/features/FeatureStrategies.java new file mode 100644 index 000000000..8ae4025f4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/FeatureStrategies.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.features; + +public class FeatureStrategies { + + public static final FeatureActivationStrategy INACTIVE = () -> false; + public static final FeatureActivationStrategy ACTIVE = () -> true; +} diff --git a/src/main/java/com/hubspot/jinjava/features/Features.java b/src/main/java/com/hubspot/jinjava/features/Features.java new file mode 100644 index 000000000..5e311f322 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/features/Features.java @@ -0,0 +1,24 @@ +package com.hubspot.jinjava.features; + +import com.hubspot.jinjava.interpret.Context; + +public class Features { + + private final FeatureConfig featureConfig; + + public Features(FeatureConfig featureConfig) { + this.featureConfig = featureConfig; + } + + public boolean isActive(String featureName, Context context) { + return getActivationStrategy(featureName).isActive(context); + } + + public boolean isActive(String featureName) { + return getActivationStrategy(featureName).isActive(); + } + + public FeatureActivationStrategy getActivationStrategy(String featureName) { + return featureConfig.getFeature(featureName); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/AutoCloseableSupplier.java b/src/main/java/com/hubspot/jinjava/interpret/AutoCloseableSupplier.java new file mode 100644 index 000000000..12a7b8dfe --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/AutoCloseableSupplier.java @@ -0,0 +1,68 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.base.Suppliers; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +public class AutoCloseableSupplier implements Supplier> { + + public static AutoCloseableSupplier of(T tSupplier) { + return of(() -> tSupplier, ignored -> {}); + } + + public static AutoCloseableSupplier of( + Supplier tSupplier, + Consumer closeConsumer + ) { + return new AutoCloseableSupplier<>( + Suppliers.memoize(() -> new AutoCloseableImpl<>(tSupplier.get(), closeConsumer)) + ); + } + + private final Supplier> autoCloseableImplWrapper; + + private AutoCloseableSupplier(Supplier> autoCloseableImplWrapper) { + this.autoCloseableImplWrapper = autoCloseableImplWrapper; + } + + @Override + public AutoCloseableImpl get() { + return autoCloseableImplWrapper.get(); + } + + public T dangerouslyGetWithoutClosing() { + return autoCloseableImplWrapper.get().value(); + } + + public AutoCloseableSupplier map(Function mapper) { + return new AutoCloseableSupplier<>(() -> { + T t = autoCloseableImplWrapper.get().value(); + return new AutoCloseableImpl<>( + mapper.apply(t), + r -> autoCloseableImplWrapper.get().closeConsumer.accept(t) + ); + }); + } + + public static class AutoCloseableImpl implements java.lang.AutoCloseable { + + private final T t; + private final Consumer closeConsumer; + + protected AutoCloseableImpl(T t, Consumer closeConsumer) { + this.t = t; + this.closeConsumer = closeConsumer; + } + + public T value() { + return t; + } + + @Override + public void close() { + closeConsumer.accept(t); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/CallStack.java b/src/main/java/com/hubspot/jinjava/interpret/CallStack.java new file mode 100644 index 000000000..f67d96d6b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/CallStack.java @@ -0,0 +1,173 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.algebra.Result; +import java.util.Optional; +import java.util.Stack; + +public class CallStack { + + private final CallStack parent; + private final Class exceptionClass; + private final Stack stack = new Stack<>(); + private final int depth; + private int topLineNumber = -1; + private int topStartPosition = -1; + + public CallStack(CallStack parent, Class exceptionClass) { + this.parent = parent; + this.exceptionClass = exceptionClass; + + this.depth = parent == null ? 0 : parent.depth + 1; + } + + public boolean contains(String path) { + if (stack.contains(path)) { + return true; + } + + if (parent != null) { + return parent.contains(path); + } + + return false; + } + + /** + * This is added to allow for recursive macro calls. Adds the given path to the + * call stack without checking for a cycle. + * @param path the path to be added. + */ + public void pushWithoutCycleCheck(String path, int lineNumber, int startPosition) { + pushToStack(path, lineNumber, startPosition); + } + + public void pushWithMaxDepth( + String path, + int maxDepth, + int lineNumber, + int startPosition + ) { + if (depth < maxDepth) { + pushToStack(path, lineNumber, startPosition); + } else { + throw TagCycleException.create( + exceptionClass, + path, + Optional.of(lineNumber), + Optional.of(startPosition) + ); + } + } + + public void push(String path, int lineNumber, int startPosition) { + if (contains(path)) { + throw TagCycleException.create( + exceptionClass, + path, + Optional.of(lineNumber), + Optional.of(startPosition) + ); + } + + pushToStack(path, lineNumber, startPosition); + } + + public Optional pop() { + if (stack.isEmpty()) { + if (parent != null) { + return parent.pop(); + } + return Optional.empty(); + } + + return Optional.of(stack.pop()); + } + + public Optional peek() { + if (stack.isEmpty()) { + if (parent != null) { + return parent.peek(); + } + return Optional.empty(); + } + + return Optional.of(stack.peek()); + } + + public int getTopLineNumber() { + if (topLineNumber == -1 && parent != null) { + return parent.getTopLineNumber(); + } + return topLineNumber; + } + + public int getTopStartPosition() { + if (topStartPosition == -1 && parent != null) { + return parent.getTopStartPosition(); + } + return topStartPosition; + } + + public boolean isEmpty() { + return stack.empty() && (parent == null || parent.isEmpty()); + } + + public AutoCloseableSupplier> closeablePush( + String path, + int lineNumber, + int startPosition + ) { + return AutoCloseableSupplier.of( + () -> { + try { + push(path, lineNumber, startPosition); + return Result.ok(path); + } catch (TagCycleException e) { + return Result.err(e); + } + }, + result -> result.ifOk(ok -> pop()) + ); + } + + public AutoCloseableSupplier closeablePushWithoutCycleCheck( + String path, + int lineNumber, + int startPosition + ) { + return AutoCloseableSupplier.of( + () -> { + pushWithoutCycleCheck(path, lineNumber, startPosition); + return path; + }, + ignored -> pop() + ); + } + + public AutoCloseableSupplier> closeablePushWithMaxDepth( + String path, + int maxDepth, + int lineNumber, + int startPosition + ) { + return AutoCloseableSupplier.of( + () -> { + try { + pushWithMaxDepth(path, maxDepth, lineNumber, startPosition); + return Result.ok(path); + } catch (TagCycleException e) { + return Result.err(e); + } + }, + result -> result.ifOk(ok -> pop()) + ); + } + + private void pushToStack(String path, int lineNumber, int startPosition) { + if (isEmpty()) { + topLineNumber = lineNumber; + topStartPosition = startPosition; + } + stack.push(path); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/CannotReconstructValueException.java b/src/main/java/com/hubspot/jinjava/interpret/CannotReconstructValueException.java new file mode 100644 index 000000000..64a7e2d83 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/CannotReconstructValueException.java @@ -0,0 +1,13 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +@Beta +public class CannotReconstructValueException extends DeferredValueException { + + public static final String CANNOT_RECONSTRUCT_MESSAGE = "Cannot reconstruct value"; + + public CannotReconstructValueException(String key) { + super(String.format("%s: %s", CANNOT_RECONSTRUCT_MESSAGE, key)); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/CollectionTooBigException.java b/src/main/java/com/hubspot/jinjava/interpret/CollectionTooBigException.java new file mode 100644 index 000000000..bae6f52a8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/CollectionTooBigException.java @@ -0,0 +1,29 @@ +package com.hubspot.jinjava.interpret; + +public class CollectionTooBigException extends RuntimeException { + + private final int maxSize; + private final int size; + + public CollectionTooBigException(int size, int maxSize) { + this.maxSize = maxSize; + this.size = size; + } + + public int getMaxSize() { + return maxSize; + } + + public int getSize() { + return size; + } + + @Override + public String getMessage() { + return String.format( + "Collection of size %d is greater than the maximum size of %d", + size, + maxSize + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/Context.java b/src/main/java/com/hubspot/jinjava/interpret/Context.java index 0af515e0d..a5fbf8544 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/Context.java +++ b/src/main/java/com/hubspot/jinjava/interpret/Context.java @@ -13,17 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. **********************************************************************/ -package com.hubspot.jinjava.interpret; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +package com.hubspot.jinjava.interpret; +import com.google.common.annotations.Beta; import com.google.common.collect.HashMultimap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; import com.hubspot.jinjava.lib.Importable; +import com.hubspot.jinjava.lib.expression.ExpressionStrategy; import com.hubspot.jinjava.lib.exptest.ExpTest; import com.hubspot.jinjava.lib.exptest.ExpTestLibrary; import com.hubspot.jinjava.lib.filter.Filter; @@ -31,14 +32,69 @@ import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; import com.hubspot.jinjava.lib.fn.FunctionLibrary; import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.tag.ForTag; import com.hubspot.jinjava.lib.tag.Tag; import com.hubspot.jinjava.lib.tag.TagLibrary; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.util.DeferredValueUtils; import com.hubspot.jinjava.util.ScopeMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.Stack; +import java.util.function.Consumer; +import java.util.stream.Collectors; public class Context extends ScopeMap { + public static final String GLOBAL_MACROS_SCOPE_KEY = "__macros__"; + public static final String IMPORT_RESOURCE_PATH_KEY = "import_resource_path"; + public static final String DEFERRED_IMPORT_RESOURCE_PATH_KEY = + "deferred_import_resource_path"; + + public static final String IMPORT_RESOURCE_ALIAS_KEY = "import_resource_alias"; - private final SetMultimap dependencies = HashMultimap.create(); + private SetMultimap dependencies = HashMultimap.create(); + private Map> disabled; + + public boolean isValidationMode() { + return contextConfiguration.isValidationMode(); + } + + public Context setValidationMode(boolean validationMode) { + contextConfiguration = contextConfiguration.withValidationMode(validationMode); + return this; + } + + public enum Library { + EXP_TEST, + FILTER, + FUNCTION, + TAG, + } + + private final CallStack extendPathStack; + private final CallStack importPathStack; + private final CallStack includePathStack; + private final CallStack macroStack; + private final CallStack fromStack; + private final CallStack currentPathStack; + + private final Set resolvedExpressions = new HashSet<>(); + private final Set resolvedValues = new HashSet<>(); + private final Set resolvedFunctions = new HashSet<>(); + + private Set deferredNodes = new HashSet<>(); + + @Beta + private Set deferredTokens = new HashSet<>(); private final ExpTestLibrary expTestLibrary; private final FilterLibrary filterLibrary; @@ -47,23 +103,121 @@ public class Context extends ScopeMap { private final Context parent; + private int renderDepth = -1; + private Boolean autoEscape; + private List superBlock; + + private final Stack renderStack = new Stack<>(); + private ContextConfiguration contextConfiguration = ContextConfiguration.of(); + private final Set metaContextVariables; // These variable names aren't tracked in eager execution + private final Set overriddenNonMetaContextVariables; + private Node currentNode; + public Context() { - this(null); + this(null, null, null, true); } public Context(Context parent) { + this(parent, null, null, true); + } + + public Context(Context parent, Map bindings) { + this(parent, bindings, null, true); + } + + public Context( + Context parent, + Map bindings, + Map> disabled + ) { + this(parent, bindings, disabled, true); + } + + public Context( + Context parent, + Map bindings, + Map> disabled, + boolean makeNewCallStacks + ) { super(parent); + this.disabled = disabled; + + if (bindings != null) { + this.putAll(bindings); + } this.parent = parent; - this.expTestLibrary = new ExpTestLibrary(parent == null); - this.filterLibrary = new FilterLibrary(parent == null); - this.functionLibrary = new FunctionLibrary(parent == null); - this.tagLibrary = new TagLibrary(parent == null); + + this.extendPathStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getExtendPathStack(), + ExtendsTagCycleException.class + ) + : parent == null ? null : parent.getExtendPathStack(); + this.importPathStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getImportPathStack(), + ImportTagCycleException.class + ) + : parent == null ? null : parent.getImportPathStack(); + this.includePathStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getIncludePathStack(), + IncludeTagCycleException.class + ) + : parent == null ? null : parent.getIncludePathStack(); + this.macroStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getMacroStack(), + MacroTagCycleException.class + ) + : parent == null ? null : parent.getMacroStack(); + this.fromStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getFromStack(), + FromTagCycleException.class + ) + : parent == null ? null : parent.getFromStack(); + this.currentPathStack = + makeNewCallStacks + ? new CallStack( + parent == null ? null : parent.getCurrentPathStack(), + TagCycleException.class + ) + : parent == null ? null : parent.getCurrentPathStack(); + + if (disabled == null) { + disabled = ImmutableMap.of(); + } + + this.expTestLibrary = + new ExpTestLibrary(parent == null, disabled.get(Library.EXP_TEST)); + this.filterLibrary = new FilterLibrary(parent == null, disabled.get(Library.FILTER)); + this.tagLibrary = new TagLibrary(parent == null, disabled.get(Library.TAG)); + this.functionLibrary = + new FunctionLibrary(parent == null, disabled.get(Library.FUNCTION)); + this.metaContextVariables = + parent == null ? new HashSet<>() : parent.metaContextVariables; + this.overriddenNonMetaContextVariables = + parent == null ? new HashSet<>() : parent.overriddenNonMetaContextVariables; + if (parent != null) { + this.contextConfiguration = parent.contextConfiguration; + } } - public Context(Context parent, Map bindings) { - this(parent); - this.putAll(bindings); + public void reset() { + // clear anything that pushes up to its parent's values + resolvedExpressions.clear(); + resolvedValues.clear(); + resolvedFunctions.clear(); + dependencies = HashMultimap.create(); + deferredNodes = new HashSet<>(); + deferredTokens = new HashSet<>(); } @Override @@ -77,7 +231,8 @@ public Map getSessionBindings() { @SuppressWarnings("unchecked") public Map getGlobalMacros() { - Map macros = (Map) getScope().get(GLOBAL_MACROS_SCOPE_KEY); + Map macros = (Map) getScope() + .get(GLOBAL_MACROS_SCOPE_KEY); if (macros == null) { macros = new HashMap<>(); @@ -105,6 +260,264 @@ public boolean isGlobalMacro(String identifier) { return getGlobalMacro(identifier) != null; } + public Optional getLocalMacro(String fullName) { + String[] nameArray = fullName.split("\\.", 2); + if (nameArray.length != 2) { + return Optional.empty(); + } + String localKey = nameArray[0]; + String macroName = nameArray[1]; + Object localValue = get(localKey); + if (localValue instanceof DeferredValue) { + localValue = ((DeferredValue) localValue).getOriginalValue(); + } + if (!(localValue instanceof Map)) { + return Optional.empty(); + } + Object possibleMacroFunction = ((Map) localValue).get(macroName); + if (possibleMacroFunction instanceof MacroFunction) { + return Optional.of((MacroFunction) possibleMacroFunction); + } + return Optional.empty(); + } + + public boolean isAutoEscape() { + if (autoEscape != null) { + return autoEscape; + } + + if (parent != null) { + return parent.isAutoEscape(); + } + + return false; + } + + public void setAutoEscape(Boolean autoEscape) { + this.autoEscape = autoEscape; + } + + public void addResolvedExpression(String expression) { + resolvedExpressions.add(expression); + if (getParent() != null) { + getParent().addResolvedExpression(expression); + } + } + + public Set getResolvedExpressions() { + return ImmutableSet.copyOf(resolvedExpressions); + } + + public boolean wasExpressionResolved(String expression) { + return resolvedExpressions.contains(expression); + } + + public void addResolvedValue(String value) { + resolvedValues.add(value); + if (getParent() != null) { + getParent().addResolvedValue(value); + } + } + + public Set getResolvedValues() { + return ImmutableSet.copyOf(resolvedValues); + } + + public boolean wasValueResolved(String value) { + return resolvedValues.contains(value); + } + + public Set getResolvedFunctions() { + return ImmutableSet.copyOf(resolvedFunctions); + } + + public void addResolvedFunction(String function) { + resolvedFunctions.add(function); + if (getParent() != null) { + getParent().addResolvedFunction(function); + } + } + + /** + * @deprecated Use {@link MetaContextVariables#isMetaContextVariable(String, Context)} + */ + @Deprecated + @Beta + public Set getMetaContextVariables() { + return metaContextVariables; + } + + @Beta + Set getComputedMetaContextVariables() { + return Sets.difference(metaContextVariables, overriddenNonMetaContextVariables); + } + + @Beta + public void addMetaContextVariables(Collection variables) { + metaContextVariables.addAll(variables); + } + + Set getNonMetaContextVariables() { + return overriddenNonMetaContextVariables; + } + + @Beta + public void addNonMetaContextVariables(Collection variables) { + overriddenNonMetaContextVariables.addAll( + variables + .stream() + .filter(var -> !EagerExecutionMode.STATIC_META_CONTEXT_VARIABLES.contains(var)) + .collect(Collectors.toList()) + ); + } + + @Beta + public void removeNonMetaContextVariables(Collection variables) { + overriddenNonMetaContextVariables.removeAll(variables); + } + + public void handleDeferredNode(Node node) { + if ( + JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getExecutionMode().useEagerParser()) + .orElse(false) + ) { + addDeferredNodeRecursively(node); + } else { + handleDeferredNodeAndDeferVariables(node); + } + } + + private void addDeferredNodeRecursively(Node node) { + deferredNodes.add(node); + if (getParent() != null) { + Context parent = getParent(); + // Ignore global context + if (parent.getParent() != null) { + getParent().handleDeferredNode(node); + } + } + } + + private void handleDeferredNodeAndDeferVariables(Node node) { + deferredNodes.add(node); + Set deferredProps = DeferredValueUtils.findAndMarkDeferredProperties( + this, + node + ); + if (getParent() != null) { + Context parent = getParent(); + // Ignore global context + if (parent.getParent() != null) { + // Place deferred values on the parent context + deferredProps + .stream() + .filter(key -> !parent.containsKey(key)) + .forEach(key -> parent.put(key, this.get(key))); + getParent().handleDeferredNode(node); + } + } + } + + public Set getDeferredNodes() { + return ImmutableSet.copyOf(deferredNodes); + } + + @Beta + public void checkNumberOfDeferredTokens() { + Context secondToLastContext = this; + if (parent != null) { + while (secondToLastContext.parent.parent != null) { + secondToLastContext = secondToLastContext.parent; + } + } + int currentNumDeferredTokens = secondToLastContext.deferredTokens.size(); + JinjavaInterpreter + .getCurrentMaybe() + .map(i -> i.getConfig().getMaxNumDeferredTokens()) + .filter(maxNumDeferredTokens -> currentNumDeferredTokens >= maxNumDeferredTokens) + .ifPresent(maxNumDeferredTokens -> { + throw new DeferredValueException( + "Too many Deferred Tokens, max is " + maxNumDeferredTokens + ); + }); + } + + @Beta + public void handleDeferredToken(DeferredToken deferredToken) { + deferredToken.addTo(this); + } + + @Beta + public void removeDeferredTokens(Collection toRemove) { + if (getParent() != null) { + Context parent = getParent(); + //Ignore global context + if (parent.getParent() != null) { + parent.removeDeferredTokens(toRemove); + } + } + deferredTokens.removeAll(toRemove); + } + + @Beta + public Set getDeferredTokens() { + return deferredTokens; + } + + public Map getCombinedScope() { + Map scopeMap = new HashMap<>(getScope()); + Context parent = this.parent; + while (parent != null && parent.currentPathStack == currentPathStack) { + parent.getScope().forEach(scopeMap::putIfAbsent); + parent = parent.parent; + } + return scopeMap; + } + + public Context getPenultimateParent() { + Context secondToLastContext = this; + if (parent != null) { + while (secondToLastContext.parent.parent != null) { + secondToLastContext = secondToLastContext.parent; + } + } + return secondToLastContext; + } + + public List getSuperBlock() { + if (superBlock != null) { + return superBlock; + } + + if (parent != null) { + return parent.getSuperBlock(); + } + + return null; + } + + public void setSuperBlock(List superBlock) { + this.superBlock = superBlock; + } + + public void removeSuperBlock() { + this.superBlock = null; + } + + /** + * Take all resolved strings from a context object and apply them to this context. + * Useful for passing resolved values up a tag hierarchy. + * + * @param context - context object to apply resolved values from. + */ + public void addResolvedFrom(Context context) { + context.getResolvedExpressions().forEach(this::addResolvedExpression); + context.getResolvedFunctions().forEach(this::addResolvedFunction); + context.getResolvedValues().forEach(this::addResolvedValue); + } + @SafeVarargs @SuppressWarnings("unchecked") public final void registerClasses(Class... classes) { @@ -169,6 +582,14 @@ public void registerFilter(Filter f) { filterLibrary.addFilter(f); } + public boolean isFunctionDisabled(String name) { + if (disabled == null) { + return false; + } + Set disabledFunctions = disabled.get(Library.FUNCTION); + return disabledFunctions != null && disabledFunctions.contains(name); + } + public ELFunctionDefinition getFunction(String name) { ELFunctionDefinition f = functionLibrary.getFunction(name); if (f != null) { @@ -186,8 +607,17 @@ public Collection getAllFunctions() { if (parent != null) { fns.addAll(parent.getAllFunctions()); } - - return fns; + if (disabled == null) { + return fns; + } + Set disabledFunctions = disabled.get(Library.FUNCTION); + if (disabledFunctions == null) { + return fns; + } + return fns + .stream() + .filter(f -> !disabledFunctions.contains(f.getName())) + .collect(Collectors.toList()); } public void registerFunction(ELFunctionDefinition f) { @@ -219,30 +649,288 @@ public void registerTag(Tag t) { tagLibrary.addTag(t); } + public DynamicVariableResolver getDynamicVariableResolver() { + return contextConfiguration.getDynamicVariableResolver(); + } + + public void setDynamicVariableResolver( + final DynamicVariableResolver dynamicVariableResolver + ) { + contextConfiguration = + contextConfiguration.withDynamicVariableResolver(dynamicVariableResolver); + } + + public ExpressionStrategy getExpressionStrategy() { + return contextConfiguration.getExpressionStrategy(); + } + + public void setExpressionStrategy(ExpressionStrategy expressionStrategy) { + contextConfiguration = + contextConfiguration.withExpressionStrategy(expressionStrategy); + } + + public Optional getImportResourceAlias() { + return Optional.ofNullable(get(IMPORT_RESOURCE_ALIAS_KEY)).map(Object::toString); + } + + public CallStack getExtendPathStack() { + return extendPathStack; + } + + public CallStack getImportPathStack() { + return importPathStack; + } + + public CallStack getFromPathStack() { + return fromStack; + } + + public CallStack getIncludePathStack() { + return includePathStack; + } + + private CallStack getFromStack() { + return fromStack; + } + + public CallStack getMacroStack() { + return macroStack; + } + + public CallStack getCurrentPathStack() { + return currentPathStack; + } + + @Deprecated + public void pushFromStack(String path, int lineNumber, int startPosition) { + fromStack.push(path, lineNumber, startPosition); + } + + @Deprecated + public void popFromStack() { + fromStack.pop(); + } + + public int getRenderDepth() { + if (renderDepth != -1) { + return renderDepth; + } + + if (parent != null) { + return parent.getRenderDepth(); + } + + return 0; + } + + public void setRenderDepth(int renderDepth) { + this.renderDepth = renderDepth; + } + + public AutoCloseableSupplier closeablePushRenderStack(String template) { + renderStack.push(template); + return AutoCloseableSupplier.of(() -> template, t -> renderStack.pop()); + } + + @Deprecated + public void pushRenderStack(String template) { + renderStack.push(template); + } + + @Deprecated + public String popRenderStack() { + return renderStack.pop(); + } + + public boolean doesRenderStackContain(String template) { + return renderStack.contains(template); + } + public void addDependency(String type, String identification) { - Context highestParentContext = getHighestParentContext(); - highestParentContext.dependencies.get(type).add(identification); + this.dependencies.get(type).add(identification); + if (parent != null) { + parent.addDependency(type, identification); + } + } + + public void addDependencies(SetMultimap dependencies) { + this.dependencies.putAll(dependencies); + if (parent != null) { + parent.addDependencies(dependencies); + } } public SetMultimap getDependencies() { - Context highestParentContext = getHighestParentContext(); - return highestParentContext.dependencies; + return this.dependencies; } - private Context getHighestParentContext() { - Context highestParentContext = parent != null ? parent : this; - Context currentParentContext = highestParentContext.getParent(); + public boolean isDeferredExecutionMode() { + return contextConfiguration.isDeferredExecutionMode(); + } - while (currentParentContext != null) { - if (currentParentContext.equals(currentParentContext.getParent())) { - return highestParentContext; - } + public Context setDeferredExecutionMode(boolean deferredExecutionMode) { + contextConfiguration = + contextConfiguration.withDeferredExecutionMode(deferredExecutionMode); + return this; + } + + public boolean isDeferLargeObjects() { + return contextConfiguration.isDeferLargeObjects(); + } + + public Context setDeferLargeObjects(boolean deferLargeObjects) { + contextConfiguration = contextConfiguration.withDeferLargeObjects(deferLargeObjects); + return this; + } + + public TemporaryValueClosable withDeferLargeObjects( + boolean deferLargeObjects + ) { + TemporaryValueClosable temporaryValueClosable = new TemporaryValueClosable<>( + isDeferLargeObjects(), + this::setDeferLargeObjects + ); + setDeferLargeObjects(deferLargeObjects); + return temporaryValueClosable; + } + + @Deprecated + public boolean getThrowInterpreterErrors() { + ErrorHandlingStrategy errorHandlingStrategy = getErrorHandlingStrategy(); + return ( + errorHandlingStrategy.getFatalErrorStrategy() == + ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.THROW_EXCEPTION + ); + } + + @Deprecated + public void setThrowInterpreterErrors(boolean throwInterpreterErrors) { + contextConfiguration = + contextConfiguration.withErrorHandlingStrategy( + ErrorHandlingStrategy + .builder() + .setFatalErrorStrategy( + throwInterpreterErrors + ? ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.THROW_EXCEPTION + : ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.ADD_ERROR + ) + .setNonFatalErrorStrategy( + throwInterpreterErrors + ? ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.IGNORE // Deprecated, warnings are ignored when doing eager expression resolving + : ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.ADD_ERROR + ) + .build() + ); + } + + @Deprecated + public TemporaryValueClosable withThrowInterpreterErrors() { + TemporaryValueClosable temporaryValueClosable = new TemporaryValueClosable<>( + getThrowInterpreterErrors(), + this::setThrowInterpreterErrors + ); + setThrowInterpreterErrors(true); + return temporaryValueClosable; + } + + public ErrorHandlingStrategy getErrorHandlingStrategy() { + return contextConfiguration.getErrorHandlingStrategy(); + } + + public void setErrorHandlingStrategy(ErrorHandlingStrategy errorHandlingStrategy) { + contextConfiguration = + contextConfiguration.withErrorHandlingStrategy(errorHandlingStrategy); + } + + public TemporaryValueClosable withErrorHandlingStrategy( + ErrorHandlingStrategy errorHandlingStrategy + ) { + TemporaryValueClosable temporaryValueClosable = + new TemporaryValueClosable<>( + getErrorHandlingStrategy(), + this::setErrorHandlingStrategy + ); + setErrorHandlingStrategy(errorHandlingStrategy); + return temporaryValueClosable; + } + + public boolean isPartialMacroEvaluation() { + return contextConfiguration.isPartialMacroEvaluation(); + } + + public void setPartialMacroEvaluation(boolean partialMacroEvaluation) { + contextConfiguration = + contextConfiguration.withPartialMacroEvaluation(partialMacroEvaluation); + } + + public TemporaryValueClosable withPartialMacroEvaluation() { + return withPartialMacroEvaluation(true); + } + + public TemporaryValueClosable withPartialMacroEvaluation( + boolean partialMacroEvaluation + ) { + TemporaryValueClosable temporaryValueClosable = new TemporaryValueClosable<>( + isPartialMacroEvaluation(), + this::setPartialMacroEvaluation + ); + setPartialMacroEvaluation(partialMacroEvaluation); + return temporaryValueClosable; + } + + public boolean isUnwrapRawOverride() { + return contextConfiguration.isUnwrapRawOverride(); + } + + public void setUnwrapRawOverride(boolean unwrapRawOverride) { + contextConfiguration = contextConfiguration.withUnwrapRawOverride(unwrapRawOverride); + } + + public TemporaryValueClosable withUnwrapRawOverride() { + return withUnwrapRawOverride(true); + } - highestParentContext = highestParentContext.getParent(); - currentParentContext = highestParentContext.getParent(); + public TemporaryValueClosable withUnwrapRawOverride(boolean value) { + TemporaryValueClosable temporaryValueClosable = new TemporaryValueClosable<>( + isUnwrapRawOverride(), + this::setUnwrapRawOverride + ); + setUnwrapRawOverride(value); + return temporaryValueClosable; + } + + public static class TemporaryValueClosable extends AutoCloseableImpl { + + private TemporaryValueClosable(T previousValue, Consumer resetValueConsumer) { + super(previousValue, resetValueConsumer); + } + public static TemporaryValueClosable noOp() { + return new NoOpTemporaryValueClosable<>(); } - return highestParentContext; + + private static class NoOpTemporaryValueClosable extends TemporaryValueClosable { + + private NoOpTemporaryValueClosable() { + super(null, null); + } + + @Override + public void close() { + // No-op + } + } + } + + public Node getCurrentNode() { + return currentNode; } + public void setCurrentNode(final Node currentNode) { + this.currentNode = currentNode; + } + + public boolean isInForLoop() { + return get(ForTag.LOOP) != null; + } } diff --git a/src/main/java/com/hubspot/jinjava/interpret/ContextConfiguration.java b/src/main/java/com/hubspot/jinjava/interpret/ContextConfiguration.java new file mode 100644 index 000000000..b902efdef --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/ContextConfiguration.java @@ -0,0 +1,54 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.JinjavaImmutableStyle; +import com.hubspot.jinjava.lib.expression.DefaultExpressionStrategy; +import com.hubspot.jinjava.lib.expression.ExpressionStrategy; +import javax.annotation.Nullable; +import org.immutables.value.Value.Default; +import org.immutables.value.Value.Immutable; + +@Immutable(singleton = true) +@JinjavaImmutableStyle +public interface ContextConfiguration extends WithContextConfiguration { + @Default + default ExpressionStrategy getExpressionStrategy() { + return new DefaultExpressionStrategy(); + } + + @Nullable + DynamicVariableResolver getDynamicVariableResolver(); + + @Default + default boolean isValidationMode() { + return false; + } + + @Default + default boolean isDeferredExecutionMode() { + return false; + } + + @Default + default boolean isDeferLargeObjects() { + return false; + } + + @Default + default boolean isPartialMacroEvaluation() { + return false; + } + + @Default + default boolean isUnwrapRawOverride() { + return false; + } + + @Default + default ErrorHandlingStrategy getErrorHandlingStrategy() { + return ImmutableErrorHandlingStrategy.of(); + } + + static ContextConfiguration of() { + return ImmutableContextConfiguration.of(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReference.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReference.java new file mode 100644 index 000000000..43d0561bd --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReference.java @@ -0,0 +1,48 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +@Beta +public class DeferredLazyReference + implements DeferredValue, Cloneable, OneTimeReconstructible { + + private final LazyReference lazyReference; + private boolean reconstructed; + + private DeferredLazyReference(LazyReference lazyReference) { + this.lazyReference = lazyReference; + } + + private DeferredLazyReference(Context referenceContext, String referenceKey) { + lazyReference = LazyReference.of(referenceContext, referenceKey); + } + + public static DeferredLazyReference instance( + Context referenceContext, + String referenceKey + ) { + return new DeferredLazyReference(referenceContext, referenceKey); + } + + @Override + public LazyReference getOriginalValue() { + return lazyReference; + } + + public boolean isReconstructed() { + return reconstructed; + } + + public void setReconstructed(boolean reconstructed) { + this.reconstructed = reconstructed; + } + + @Override + public DeferredLazyReference clone() { + try { + return (DeferredLazyReference) super.clone(); + } catch (CloneNotSupportedException e) { + return new DeferredLazyReference(lazyReference); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReferenceSource.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReferenceSource.java new file mode 100644 index 000000000..f73c1f8ee --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredLazyReferenceSource.java @@ -0,0 +1,36 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +@Beta +public class DeferredLazyReferenceSource + extends DeferredValueImpl + implements OneTimeReconstructible { + + private static final DeferredLazyReferenceSource INSTANCE = + new DeferredLazyReferenceSource(); + + private boolean reconstructed; + + private DeferredLazyReferenceSource() {} + + private DeferredLazyReferenceSource(Object originalValue) { + super(originalValue); + } + + public static DeferredLazyReferenceSource instance() { + return INSTANCE; + } + + public static DeferredLazyReferenceSource instance(Object originalValue) { + return new DeferredLazyReferenceSource(originalValue); + } + + public boolean isReconstructed() { + return reconstructed; + } + + public void setReconstructed(boolean reconstructed) { + this.reconstructed = reconstructed; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredMacroValueImpl.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredMacroValueImpl.java new file mode 100644 index 000000000..3aaaf20ec --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredMacroValueImpl.java @@ -0,0 +1,20 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +@Beta +public class DeferredMacroValueImpl implements DeferredValue { + + private static final DeferredValue INSTANCE = new DeferredMacroValueImpl(); + + private DeferredMacroValueImpl() {} + + @Override + public Object getOriginalValue() { + return null; + } + + public static DeferredValue instance() { + return INSTANCE; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredValue.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredValue.java new file mode 100644 index 000000000..8cd6bfc58 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredValue.java @@ -0,0 +1,22 @@ +package com.hubspot.jinjava.interpret; + +/** + * Marker object which indicates that the template engine should skip over evaluating + * this part of the template, if the object is resolved from the context. + * + */ +public interface DeferredValue { + Object getOriginalValue(); + + static DeferredValue instance() { + return DeferredValueImpl.instance(); + } + + static DeferredValue instance(Object originalValue) { + return DeferredValueImpl.instance(originalValue); + } + + static DeferredValueShadow shadowInstance(Object originalValue) { + return DeferredValueShadow.instance(originalValue); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredValueException.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueException.java new file mode 100644 index 000000000..884f59e7c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueException.java @@ -0,0 +1,19 @@ +package com.hubspot.jinjava.interpret; + +/** + * Exception thrown when attempting to render a {@link com.hubspot.jinjava.interpret.DeferredValue}. + * The exception is effectively used for flow control, to unwind evaluating a template Node + * and instead echo its contents to the output. + */ +public class DeferredValueException extends InterpretException { + + public static final String MESSAGE_PREFIX = "Encountered a deferred value: "; + + public DeferredValueException(String message) { + super(message); + } + + public DeferredValueException(String variable, int lineNumber, int startPosition) { + super(MESSAGE_PREFIX + '\"' + variable + '\"', lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredValueImpl.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueImpl.java new file mode 100644 index 000000000..4b001ca94 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueImpl.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.interpret; + +import java.util.Objects; + +public class DeferredValueImpl implements DeferredValue { + + private static final DeferredValue INSTANCE = new DeferredValueImpl(); + + private Object originalValue; + + protected DeferredValueImpl() {} + + protected DeferredValueImpl(Object originalValue) { + this.originalValue = originalValue; + } + + @Override + public Object getOriginalValue() { + return originalValue; + } + + protected static DeferredValue instance() { + return INSTANCE; + } + + protected static DeferredValue instance(Object originalValue) { + return new DeferredValueImpl(originalValue); + } + + @Override + public String toString() { + return Objects.toString(originalValue); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DeferredValueShadow.java b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueShadow.java new file mode 100644 index 000000000..c1dddf72a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DeferredValueShadow.java @@ -0,0 +1,25 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +/** + * A deferred value which represents that a value was deferred within this context, + * but it is does not overwrite the actual key in which the original value resides on the context. + */ +@Beta +public class DeferredValueShadow extends DeferredValueImpl { + + protected DeferredValueShadow() {} + + protected DeferredValueShadow(Object originalValue) { + super(originalValue); + } + + protected static DeferredValueShadow instance() { + return new DeferredValueShadow(); + } + + protected static DeferredValueShadow instance(Object originalValue) { + return new DeferredValueShadow(originalValue); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DisabledException.java b/src/main/java/com/hubspot/jinjava/interpret/DisabledException.java new file mode 100644 index 000000000..d1521a2b2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DisabledException.java @@ -0,0 +1,15 @@ +package com.hubspot.jinjava.interpret; + +public class DisabledException extends InterpretException { + + private final String token; + + public DisabledException(String token) { + super("'" + token + "' is disabled in this context"); + this.token = token; + } + + public String getToken() { + return token; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/DynamicVariableResolver.java b/src/main/java/com/hubspot/jinjava/interpret/DynamicVariableResolver.java new file mode 100644 index 000000000..aaee1c324 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/DynamicVariableResolver.java @@ -0,0 +1,18 @@ +/** + * 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.hubspot.jinjava.interpret; + +import java.util.function.Function; + +public interface DynamicVariableResolver extends Function {} diff --git a/src/main/java/com/hubspot/jinjava/interpret/ErrorHandlingStrategy.java b/src/main/java/com/hubspot/jinjava/interpret/ErrorHandlingStrategy.java new file mode 100644 index 000000000..23df9c977 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/ErrorHandlingStrategy.java @@ -0,0 +1,46 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.JinjavaImmutableStyle; +import org.immutables.value.Value; + +@Value.Immutable(singleton = true) +@JinjavaImmutableStyle +public interface ErrorHandlingStrategy { + @Value.Default + default TemplateErrorTypeHandlingStrategy getFatalErrorStrategy() { + return TemplateErrorTypeHandlingStrategy.ADD_ERROR; + } + + @Value.Default + default TemplateErrorTypeHandlingStrategy getNonFatalErrorStrategy() { + return TemplateErrorTypeHandlingStrategy.ADD_ERROR; + } + + enum TemplateErrorTypeHandlingStrategy { + IGNORE, + ADD_ERROR, + THROW_EXCEPTION, + } + + class Builder extends ImmutableErrorHandlingStrategy.Builder {} + + static Builder builder() { + return new Builder(); + } + + static ErrorHandlingStrategy throwAll() { + return ErrorHandlingStrategy + .builder() + .setFatalErrorStrategy(TemplateErrorTypeHandlingStrategy.THROW_EXCEPTION) + .setNonFatalErrorStrategy(TemplateErrorTypeHandlingStrategy.THROW_EXCEPTION) + .build(); + } + + static ErrorHandlingStrategy ignoreAll() { + return ErrorHandlingStrategy + .builder() + .setFatalErrorStrategy(TemplateErrorTypeHandlingStrategy.IGNORE) + .setNonFatalErrorStrategy(TemplateErrorTypeHandlingStrategy.IGNORE) + .build(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/ExtendsTagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/ExtendsTagCycleException.java new file mode 100644 index 000000000..3c3a63431 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/ExtendsTagCycleException.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +public class ExtendsTagCycleException extends TagCycleException { + + private static final long serialVersionUID = 3183769038400532542L; + + public ExtendsTagCycleException(String path, int lineNumber, int startPosition) { + super("Extends", path, lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/FatalTemplateErrorsException.java b/src/main/java/com/hubspot/jinjava/interpret/FatalTemplateErrorsException.java index 4662158fb..41c1f69e6 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/FatalTemplateErrorsException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/FatalTemplateErrorsException.java @@ -1,30 +1,33 @@ package com.hubspot.jinjava.interpret; +import java.util.Collection; + /** * Container exception thrown when fatal errors are encountered while rendering a template. * * @author jstehler */ public class FatalTemplateErrorsException extends InterpretException { + private static final long serialVersionUID = 1L; private final String template; private final Iterable errors; - public FatalTemplateErrorsException(String template, Iterable errors) { + public FatalTemplateErrorsException(String template, Collection errors) { super(generateMessage(errors)); this.template = template; this.errors = errors; } - private static String generateMessage(Iterable errors) { - StringBuilder msg = new StringBuilder(); - - for (TemplateError error : errors) { - msg.append(error.toString()).append('\n'); + private static String generateMessage(Collection errors) { + if (errors.isEmpty()) { + throw new IllegalArgumentException( + "FatalTemplateErrorsException should have at least one error" + ); } - return msg.toString(); + return errors.iterator().next().getMessage(); } public String getTemplate() { @@ -34,5 +37,4 @@ public String getTemplate() { public Iterable getErrors() { return errors; } - } diff --git a/src/main/java/com/hubspot/jinjava/interpret/FromTagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/FromTagCycleException.java new file mode 100644 index 000000000..35715af8e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/FromTagCycleException.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +public class FromTagCycleException extends TagCycleException { + + private static final long serialVersionUID = -5487642459443650227L; + + public FromTagCycleException(String path, int lineNumber, int startPosition) { + super("From", path, lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/ImportTagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/ImportTagCycleException.java new file mode 100644 index 000000000..4b37b9dc3 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/ImportTagCycleException.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +public class ImportTagCycleException extends TagCycleException { + + private static final long serialVersionUID = 1092085697026161185L; + + public ImportTagCycleException(String path, int lineNumber, int startPosition) { + super("Import", path, lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/IncludeTagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/IncludeTagCycleException.java new file mode 100644 index 000000000..5060b3079 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/IncludeTagCycleException.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +public class IncludeTagCycleException extends TagCycleException { + + private static final long serialVersionUID = -5487642459443650227L; + + public IncludeTagCycleException(String path, int lineNumber, int startPosition) { + super("Include", path, lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/IndexOutOfRangeException.java b/src/main/java/com/hubspot/jinjava/interpret/IndexOutOfRangeException.java new file mode 100644 index 000000000..29688f2e5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/IndexOutOfRangeException.java @@ -0,0 +1,8 @@ +package com.hubspot.jinjava.interpret; + +public class IndexOutOfRangeException extends InterpretException { + + public IndexOutOfRangeException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/InterpretException.java b/src/main/java/com/hubspot/jinjava/interpret/InterpretException.java index 1bbedb434..476d0dd99 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/InterpretException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/InterpretException.java @@ -17,9 +17,10 @@ public class InterpretException extends RuntimeException { - private static final long serialVersionUID = -3471306977643116138L; + private static final long serialVersionUID = -3471306977643126138L; private int lineNumber = -1; + private int startPosition = -1; public InterpretException(String msg) { super(msg); @@ -30,17 +31,30 @@ public InterpretException(String msg, Throwable e) { } public InterpretException(String msg, int lineNumber) { + this(msg, lineNumber, -1); + } + + public InterpretException(String msg, int lineNumber, int startPosition) { this(msg); this.lineNumber = lineNumber; + this.startPosition = startPosition; } - public InterpretException(String msg, Throwable e, int lineNumber) { + public InterpretException(String msg, Throwable e, int lineNumber, int startPosition) { this(msg, e); this.lineNumber = lineNumber; + this.startPosition = startPosition; + } + + public InterpretException(String msg, Throwable throwable, int lineNumber) { + this(msg, throwable, lineNumber, -1); } public int getLineNumber() { return lineNumber; } + public int getStartPosition() { + return startPosition; + } } diff --git a/src/main/java/com/hubspot/jinjava/interpret/InterpreterFactory.java b/src/main/java/com/hubspot/jinjava/interpret/InterpreterFactory.java new file mode 100644 index 000000000..3990f1d16 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/InterpreterFactory.java @@ -0,0 +1,13 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; + +public interface InterpreterFactory { + JinjavaInterpreter newInstance(JinjavaInterpreter orig); + JinjavaInterpreter newInstance( + Jinjava application, + Context context, + JinjavaConfig renderConfig + ); +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/InvalidArgumentException.java b/src/main/java/com/hubspot/jinjava/interpret/InvalidArgumentException.java new file mode 100644 index 000000000..2c4272d85 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/InvalidArgumentException.java @@ -0,0 +1,97 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.lib.Importable; + +public class InvalidArgumentException extends RuntimeException { + + private final int lineNumber; + private final int startPosition; + private final String message; + private final String name; + + public InvalidArgumentException( + JinjavaInterpreter interpreter, + Importable importable, + InvalidReason invalidReason, + int argumentNumber, + Object... errorMessageArgs + ) { + this( + interpreter, + importable.getName(), + String.format( + "Invalid argument in '%s': %s", + importable.getName(), + String.format( + "%s argument %s", + formatArgumentNumber(argumentNumber + 1), + String.format(invalidReason.getErrorMessage(), errorMessageArgs) + ) + ) + ); + } + + public InvalidArgumentException( + JinjavaInterpreter interpreter, + Importable importable, + InvalidReason invalidReason, + String argumentName, + Object... errorMessageArgs + ) { + this( + interpreter, + importable.getName(), + String.format( + "Invalid argument in '%s': %s", + importable.getName(), + String.format( + "'%s' argument %s", + argumentName, + String.format(invalidReason.getErrorMessage(), errorMessageArgs) + ) + ) + ); + } + + public InvalidArgumentException( + JinjavaInterpreter interpreter, + String name, + String errorMessage + ) { + this.message = errorMessage; + + this.lineNumber = interpreter.getLineNumber(); + this.startPosition = interpreter.getPosition(); + this.name = name; + } + + public String getMessage() { + return message; + } + + public int getLineNumber() { + return lineNumber; + } + + public int getStartPosition() { + return startPosition; + } + + public String getName() { + return name; + } + + private static String formatArgumentNumber(int argumentNumber) { + String base = "th"; + int remainder = argumentNumber % 10; + if (remainder == 1) { + base = "st"; + } else if (remainder == 2) { + base = "nd"; + } else if (remainder == 3) { + base = "rd"; + } + + return String.format("%d%s", argumentNumber, base); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/InvalidInputException.java b/src/main/java/com/hubspot/jinjava/interpret/InvalidInputException.java new file mode 100644 index 000000000..96d39f35a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/InvalidInputException.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.lib.Importable; + +public class InvalidInputException extends RuntimeException { + + private final int lineNumber; + private final int startPosition; + private final String message; + private final String name; + + public InvalidInputException( + JinjavaInterpreter interpreter, + Importable importable, + InvalidReason invalidReason, + Object... errorMessageArgs + ) { + this( + interpreter, + importable.getName(), + String.format( + "Invalid input for '%s': input %s", + importable.getName(), + String.format(invalidReason.getErrorMessage(), errorMessageArgs) + ) + ); + } + + public InvalidInputException( + JinjavaInterpreter interpreter, + String name, + String errorMessage + ) { + this.message = errorMessage; + + this.lineNumber = interpreter.getLineNumber(); + this.startPosition = interpreter.getPosition(); + this.name = name; + } + + public String getMessage() { + return message; + } + + public int getLineNumber() { + return lineNumber; + } + + public int getStartPosition() { + return startPosition; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/InvalidReason.java b/src/main/java/com/hubspot/jinjava/interpret/InvalidReason.java new file mode 100644 index 000000000..a662e493b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/InvalidReason.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.interpret; + +public enum InvalidReason { + NUMBER_FORMAT("with value '%s' must be a number"), + NULL("cannot be null"), + STRING("must be a string"), + EXPRESSION_TEST("with value %s must be the name of an expression test"), + TEMPORAL_UNIT("with value %s must be a valid temporal unit"), + JSON_READ("could not be converted to an object"), + JSON_WRITE("object could not be written as a string"), + REGEX("with value %s must be valid regex"), + POSITIVE_NUMBER("with value %s must be a positive number"), + NOT_ITERABLE("with value '%s' must be iterable"), + NON_ZERO_NUMBER("with value %s must be non-zero"), + NULL_IN_LIST("of type 'list' cannot contain a null item"), + NULL_ATTRIBUTE_IN_LIST( + "with value '%s' must be a valid attribute of every item in the list" + ), + ENUM("with value '%s' must be one of: %s"), + CIDR("with value '%s' must be a valid CIDR address"), + LENGTH("with length '%s' exceeds maximum allowed length of '%s'"); + + private final String errorMessage; + + InvalidReason(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java index 739b8e250..4034ee43c 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java +++ b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java @@ -1,68 +1,171 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.interpret; import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; -import java.io.IOException; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Stack; - -import org.apache.commons.lang3.StringUtils; - +import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; import com.google.common.collect.Multimap; +import com.hubspot.algebra.Result; import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.el.ExpressionResolver; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.ExtendedParser; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.ExtendsTag; +import com.hubspot.jinjava.lib.tag.eager.EagerGenericTag; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import com.hubspot.jinjava.random.ConstantZeroRandomNumberGenerator; +import com.hubspot.jinjava.random.DeferredRandomNumberGenerator; +import com.hubspot.jinjava.tree.ExpressionNode; import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.tree.TreeParser; +import com.hubspot.jinjava.tree.output.BlockInfo; +import com.hubspot.jinjava.tree.output.BlockPlaceholderOutputNode; +import com.hubspot.jinjava.tree.output.DynamicRenderedOutputNode; +import com.hubspot.jinjava.tree.output.OutputList; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.util.DeferredValueUtils; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.RenderLimitUtils; import com.hubspot.jinjava.util.Variable; import com.hubspot.jinjava.util.WhitespaceUtils; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.Stack; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; + +public class JinjavaInterpreter implements PyishSerializable { -public class JinjavaInterpreter { + public static final String IGNORED_OUTPUT_FROM_EXTENDS_NOTE = + "ignored_output_from_extends"; - private final Multimap> blocks = ArrayListMultimap.create(); + public static final String OUTPUT_UNDEFINED_VARIABLES_ERROR = + BuiltInFeatures.OUTPUT_UNDEFINED_VARIABLES_ERROR; + public static final String IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS = + BuiltInFeatures.IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS; + private final Multimap blocks = ArrayListMultimap.create(); private final LinkedList extendParentRoots = new LinkedList<>(); + private final Map revertibleObjects = new HashMap<>(); private Context context; private final JinjavaConfig config; private final ExpressionResolver expressionResolver; private final Jinjava application; + private final Random random; private int lineNumber = -1; - private final List errors = new LinkedList<>(); - - public JinjavaInterpreter(Jinjava application, Context context, JinjavaConfig renderConfig) { + private int position = 0; + private int scopeDepth = 1; + private BlockInfo currentBlock; + private final List errors = new ArrayList<>(); + private final Set errorSet = new HashSet<>(); + + private static final int MAX_ERROR_SIZE = 100; + + public JinjavaInterpreter( + Jinjava application, + Context context, + JinjavaConfig renderConfig + ) { this.context = context; this.config = renderConfig; this.application = application; + this.config.getExecutionMode().prepareContext(this.context); + + switch (config.getRandomNumberGeneratorStrategy()) { + case THREAD_LOCAL: + random = ThreadLocalRandom.current(); + break; + case CONSTANT_ZERO: + random = new ConstantZeroRandomNumberGenerator(); + break; + case DEFERRED: + random = new DeferredRandomNumberGenerator(); + break; + default: + throw new IllegalStateException( + "No random number generator with strategy " + + config.getRandomNumberGeneratorStrategy() + ); + } - this.expressionResolver = new ExpressionResolver(this, application.getExpressionFactory()); + this.expressionResolver = new ExpressionResolver(this, application); } public JinjavaInterpreter(JinjavaInterpreter orig) { this(orig.application, new Context(orig.context), orig.config); + scopeDepth = orig.getScopeDepth() + 1; } + public static void checkOutputSize(String string) { + if (isOutputTooLarge(string)) { + throw new OutputTooBigException( + getCurrent().getConfig().getMaxOutputSize(), + string.length() + ); + } + } + + public static boolean isOutputTooLarge(String string) { + Optional maxStringLength = getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getMaxOutputSize()) + .filter(max -> max > 0); + return ( + maxStringLength.map(max -> string != null && string.length() > max).orElse(false) + ); + } + + /** + * @deprecated use {{@link #getConfig()}} + */ + @Deprecated public JinjavaConfig getConfiguration() { return config; } @@ -71,112 +174,471 @@ public void addExtendParentRoot(Node root) { extendParentRoots.add(root); } - public void addBlock(String name, LinkedList value) { - blocks.put(name, value); + public void addBlock(String name, BlockInfo blockInfo) { + blocks.put(name, blockInfo); + } + + /** + * Creates a new variable scope, extending from the current scope. Allows you to create a nested + * contextual scope which can override variables from higher levels. + *

+ * Should be used in a try/finally context, similar to lock-use patterns: + *

+ * + * interpreter.enterScope(); + * try (interpreter.enterScope()) { + * // ... + * } + * + */ + public InterpreterScopeClosable enterScope() { + return enterScope(null); + } + + public InterpreterScopeClosable enterScope(Map> disabled) { + context = new Context(context, null, disabled); + scopeDepth++; + return new InterpreterScopeClosable(); } - public void enterScope() { - context = new Context(context); + public InterpreterScopeClosable enterNonStackingScope() { + context = new Context(context, null, null, false); + scopeDepth++; + return new InterpreterScopeClosable(); } public void leaveScope() { Context parent = context.getParent(); + scopeDepth--; if (parent != null) { + parent.addDependencies(context.getDependencies()); context = parent; } } + public Random getRandom() { + return random; + } + + public boolean isValidationMode() { + return config.isValidationMode(); + } + + public Map getRevertibleObjects() { + return revertibleObjects; + } + + public class InterpreterScopeClosable implements AutoCloseable { + + @Override + public void close() { + leaveScope(); + } + } + public Node parse(String template) { return new TreeParser(this, template).buildTree(); } - public String renderString(String template) { - Integer depth = (Integer) context.get("hs_render_depth", 0); - if (depth == null) { - depth = 0; - } + /** + * Parse the given string into a root Node, and then render it without processing any extend parents. + * This method should be used when the template is known to not have any extends or block tags. + * + * @param template + * string to parse + * @return rendered result + */ + public String renderFlat(String template) { + return renderFlat(template, config.getMaxOutputSize()); + } + + /** + * Parse the given string into a root Node, and then render it without processing any extend parents. + * This method should be used when the template is known to not have any extends or block tags. + * + * @param template + * string to parse + * @param renderLimit + * stop rendering once this output length is reached + * @return rendered result + */ + public String renderFlat(String template, long renderLimit) { + int depth = context.getRenderDepth(); try { if (depth > config.getMaxRenderDepth()) { - ENGINE_LOG.warn("Max render depth exceeded: {}", depth); + ENGINE_LOG.warn("Max render depth exceeded: {}", Integer.toString(depth)); return template; } else { - context.put("hs_render_depth", depth + 1); - return render(parse(template), false); + context.setRenderDepth(depth + 1); + Node parsedNode; + try ( + TemporaryValueClosable c = ignoreParseErrorsIfActivated() + ) { + parsedNode = parse(template); + } + return render(parsedNode, false, renderLimit); } } finally { - context.put("hs_render_depth", depth); + context.setRenderDepth(depth); } } - public String render(Node root) { - return render(root, true); + private TemporaryValueClosable ignoreParseErrorsIfActivated() { + return config + .getFeatures() + .getActivationStrategy(BuiltInFeatures.IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS) + .isActive(context) + ? context.withErrorHandlingStrategy(ErrorHandlingStrategy.ignoreAll()) + : TemporaryValueClosable.noOp(); } + /** + * Parse the given string into a root Node, and then renders it processing extend parents. + * + * @param template + * string to parse + * @return rendered result + */ public String render(String template) { - ENGINE_LOG.debug(template); - return render(parse(template), true); + return render(template, config.getMaxOutputSize()); } + /** + * Parse the given string into a root Node, and then renders it processing extend parents. + * + * @param template + * string to parse + * @param renderLimit + * stop rendering once this output length is reached + * @return rendered result + */ + public String render(String template, long renderLimit) { + return render(parse(template), true, renderLimit); + } + + /** + * Render the given root node, processing extend parents. Equivalent to render(root, true) + * + * @param root + * node to render + * @return rendered result + */ + public String render(Node root) { + return render(root, true, config.getMaxOutputSize()); + } + + /** + * Render the given root node with an option to process extend parents. + * Equivalent to render(root, processExtendRoots). + * @param root + * node to render + * @param processExtendRoots + * @return + */ public String render(Node root, boolean processExtendRoots) { - StringBuilder buff = new StringBuilder(); + return render(root, processExtendRoots, config.getMaxOutputSize()); + } - for (Node node : root.getChildren()) { - buff.append(node.render(this)); + /** + * Render the given root node using this interpreter's current context + * + * @param root + * node to render + * @param processExtendRoots + * if true, also render all extend parents + * @param renderLimit + * stop rendering once this output length is reached + * @return rendered result + */ + private String render(Node root, boolean processExtendRoots, long renderLimit) { + boolean pushed = false; + //noinspection ErrorProne + if (JinjavaInterpreter.getCurrent() != this) { + JinjavaInterpreter.pushCurrent(this); + pushed = true; } + try { + OutputList output = new OutputList( + RenderLimitUtils.clampProvidedRenderLimitToConfig(renderLimit, config) + ); + for (Node node : root.getChildren()) { + lineNumber = node.getLineNumber(); + position = node.getStartPosition(); + String renderStr = node.getMaster().getImage(); + try { + if ( + node instanceof ExpressionNode && context.doesRenderStackContain(renderStr) + ) { + // This is a circular rendering. Stop rendering it here. + addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Rendering cycle detected: '" + renderStr + "'", + null, + getLineNumber(), + node.getStartPosition(), + null, + BasicTemplateErrorCategory.IMPORT_CYCLE_DETECTED, + ImmutableMap.of("string", renderStr) + ) + ); + output.addNode(new RenderedOutputNode(renderStr)); + } else { + OutputNode out; + try ( + AutoCloseableImpl closeable = context + .closeablePushRenderStack(renderStr) + .get() + ) { + try { + out = node.render(this); + } catch (DeferredValueException e) { + context.handleDeferredNode(node); + out = new RenderedOutputNode(node.getMaster().getImage()); + } + } + output.addNode(out); + } + } catch (OutputTooBigException e) { + addError(TemplateError.fromOutputTooBigException(e)); + return output.getValue(); + } catch (CollectionTooBigException e) { + addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.COLLECTION_TOO_BIG, + ErrorItem.OTHER, + ExceptionUtils.getMessage(e), + null, + -1, + -1, + e, + BasicTemplateErrorCategory.UNKNOWN, + ImmutableMap.of() + ) + ); + return output.getValue(); + } + } + DynamicRenderedOutputNode pathSetter = new DynamicRenderedOutputNode(); + output.addNode(pathSetter); + Optional basePath = context.getCurrentPathStack().peek(); + StringBuilder ignoredOutput = new StringBuilder(); + boolean preserveBlocks = false; + // render all extend parents, keeping the last as the root output + if (processExtendRoots) { + Set extendPaths = new HashSet<>(); + Optional extendPath = context.getExtendPathStack().peek(); + int numDeferredTokensBefore = 0; + while (!extendParentRoots.isEmpty()) { + if (extendPaths.contains(extendPath.orElse(""))) { + addError( + TemplateError.fromException( + new ExtendsTagCycleException( + extendPath.orElse(""), + context.getExtendPathStack().getTopLineNumber(), + context.getExtendPathStack().getTopStartPosition() + ) + ) + ); + break; + } + extendPaths.add(extendPath.orElse("")); + try ( + AutoCloseableImpl> closeableCurrentPath = + context + .getCurrentPathStack() + .closeablePush( + extendPath.orElse(""), + context.getExtendPathStack().getTopLineNumber(), + context.getExtendPathStack().getTopStartPosition() + ) + .get() + ) { + String currentPath = closeableCurrentPath + .value() + .unwrapOrElseThrow(Function.identity()); + Node parentRoot = extendParentRoots.removeFirst(); + if (context.getDeferredTokens().size() > numDeferredTokensBefore) { + ignoredOutput.append( + output + .getNodes() + .stream() + .filter(node -> node instanceof RenderedOutputNode) + .map(OutputNode::getValue) + .collect(Collectors.joining()) + ); + } + numDeferredTokensBefore = context.getDeferredTokens().size(); + output = new OutputList(config.getMaxOutputSize()); + output.addNode(pathSetter); + boolean hasNestedExtends = false; + for (Node node : parentRoot.getChildren()) { + lineNumber = node.getLineNumber() - 1; // The line number is off by one when rendering the extend parent + position = node.getStartPosition(); + try { + OutputNode out = node.render(this); + output.addNode(out); + if (isExtendsTag(node)) { + hasNestedExtends = true; + } + } catch (OutputTooBigException e) { + addError(TemplateError.fromOutputTooBigException(e)); + return output.getValue(); + } + } + Optional currentExtendPath = context.getExtendPathStack().pop(); + extendPath = + hasNestedExtends ? currentExtendPath : context.getExtendPathStack().peek(); + basePath = Optional.of(currentPath); + } + } + preserveBlocks = (context.getDeferredTokens().size() > numDeferredTokensBefore); + } - // render all extend parents, keeping the last as the root output - if (processExtendRoots) { - while (!extendParentRoots.isEmpty()) { - Node parentRoot = extendParentRoots.removeFirst(); - buff = new StringBuilder(); - - for (Node node : parentRoot.getChildren()) { - buff.append(node.render(this)); + int numDeferredTokensBefore = context.getDeferredTokens().size(); + resolveBlockStubs(output); + if (preserveBlocks) { + for (BlockPlaceholderOutputNode blockPlaceholder : output.getBlocks()) { + blockPlaceholder.resolve( + EagerReconstructionUtils.wrapInTag( + blockPlaceholder.getValue(), + "block %s".formatted(blockPlaceholder.getBlockName()), + this, + false + ) + ); } } - } + if (context.getDeferredTokens().size() > numDeferredTokensBefore) { + pathSetter.setValue( + EagerReconstructionUtils.buildBlockOrInlineSetTag( + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + basePath, + this + ) + ); + } - return resolveBlockStubs(buff); + if (ignoredOutput.length() > 0) { + return ( + EagerReconstructionUtils.labelWithNotes( + EagerReconstructionUtils.wrapInTag( + ignoredOutput.toString(), + DoTag.TAG_NAME, + this, + false + ), + IGNORED_OUTPUT_FROM_EXTENDS_NOTE, + this + ) + + output.getValue() + ); + } + return output.getValue(); + } finally { + if (pushed) { + JinjavaInterpreter.popCurrent(); + } + } } - String resolveBlockStubs(CharSequence content) { - StringBuilder result = new StringBuilder(content.length() + 256); - int pos = 0, start, end, stubStartLen = BLOCK_STUB_START.length(); - - while ((start = StringUtils.indexOf(content, BLOCK_STUB_START, pos)) != -1) { - end = StringUtils.indexOf(content, BLOCK_STUB_END, start + stubStartLen); - - String blockName = content.subSequence(start + stubStartLen, end).toString(); - - String blockValue = ""; - - Collection> blockChain = blocks.get(blockName); - List block = Iterables.getFirst(blockChain, null); + private void resolveBlockStubs(OutputList output) { + resolveBlockStubs(output, new Stack<>()); + } - if (block != null) { - List superBlock = Iterables.get(blockChain, 1, null); - context.put("__superbl0ck__", superBlock); + private boolean isExtendsTag(Node node) { + return ( + node instanceof TagNode && + (((TagNode) node).getTag() instanceof ExtendsTag || + isEagerExtendsTag((TagNode) node)) + ); + } - StringBuilder blockValueBuilder = new StringBuilder(); + private boolean isEagerExtendsTag(TagNode node) { + return ( + node.getTag() instanceof EagerGenericTag && + ((EagerGenericTag) node.getTag()).getTag() instanceof ExtendsTag + ); + } - for (Node child : block) { - blockValueBuilder.append(child.render(this)); + private void resolveBlockStubs(OutputList output, Stack blockNames) { + for (BlockPlaceholderOutputNode blockPlaceholder : output.getBlocks()) { + if (!blockNames.contains(blockPlaceholder.getBlockName())) { + Collection blockChain = blocks.get(blockPlaceholder.getBlockName()); + BlockInfo block = Iterables.getFirst(blockChain, null); + + if (block != null && block.getNodes() != null) { + List superBlock = Optional + .ofNullable(Iterables.get(blockChain, 1, null)) + .map(BlockInfo::getNodes) + .orElse(null); + context.setSuperBlock(superBlock); + currentBlock = block; + + OutputList blockValueBuilder = new OutputList(config.getMaxOutputSize()); + DynamicRenderedOutputNode prefix = new DynamicRenderedOutputNode(); + blockValueBuilder.addNode(prefix); + int numDeferredTokensBefore = context.getDeferredTokens().size(); + + try ( + AutoCloseableImpl parentPathPush = conditionallyPushParentPath(block) + .get() + ) { + if (parentPathPush.value()) { + lineNumber--; // The line number is off by one when rendering the block from the parent template + } + + for (Node child : block.getNodes()) { + lineNumber = child.getLineNumber(); + position = child.getStartPosition(); + + blockValueBuilder.addNode(child.render(this)); + } + if (context.getDeferredTokens().size() > numDeferredTokensBefore) { + EagerReconstructionUtils.reconstructPathAroundBlock( + prefix, + blockValueBuilder, + this + ); + } + } + blockNames.push(blockPlaceholder.getBlockName()); + resolveBlockStubs(blockValueBuilder, blockNames); + blockNames.pop(); + + context.removeSuperBlock(); + currentBlock = null; + + blockPlaceholder.resolve(blockValueBuilder.getValue()); } - - blockValue = resolveBlockStubs(blockValueBuilder); - - context.remove("__superbl0ck__"); } - result.append(content.subSequence(pos, start)); - result.append(blockValue); - pos = end + 1; + if (!blockPlaceholder.isResolved()) { + blockPlaceholder.resolve(""); + } } + } - result.append(content.subSequence(pos, content.length())); - - return result.toString(); + private AutoCloseableSupplier conditionallyPushParentPath(BlockInfo block) { + if ( + block.getParentPath().isPresent() && + !getContext().getCurrentPathStack().contains(block.getParentPath().get()) + ) { + return getContext() + .getCurrentPathStack() + .closeablePush( + block.getParentPath().get(), + block.getParentLineNo(), + block.getParentPosition() + ) + .map(path -> true); + } else { + return AutoCloseableSupplier.of(false); + } } /** @@ -186,21 +648,59 @@ String resolveBlockStubs(CharSequence content) { * name of variable in context * @param lineNumber * current line number, for error reporting + * @param startPosition + * current line position, for error reporting * @return resolved value for variable */ - public Object retraceVariable(String variable, int lineNumber) { + public Object retraceVariable(String variable, int lineNumber, int startPosition) { if (StringUtils.isBlank(variable)) { return ""; } Variable var = new Variable(this, variable); String varName = var.getName(); Object obj = context.get(varName); + if (obj == null && context.getDynamicVariableResolver() != null) { + obj = context.getDynamicVariableResolver().apply(varName); + } if (obj != null) { + if (DeferredValueUtils.isFullyDeferred(obj)) { + if (config.getExecutionMode().useEagerParser()) { + throw new DeferredParsingException(this, variable); + } else { + throw new DeferredValueException(variable, lineNumber, startPosition); + } + } obj = var.resolve(obj); + } else { + if ( + getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.OUTPUT_UNDEFINED_VARIABLES_ERROR) + .isActive(context) + ) { + addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.UNKNOWN, + ErrorItem.TOKEN, + "Undefined variable: '" + variable + "'", + null, + lineNumber, + startPosition, + null, + BasicTemplateErrorCategory.UNKNOWN, + ImmutableMap.of("variable", variable) + ) + ); + } } return obj; } + public Object retraceVariable(String variable, int lineNumber) { + return retraceVariable(variable, lineNumber, -1); + } + /** * Resolve a variable into an object value. If given a string literal (e.g. 'foo' or "foo"), this method returns the literal unquoted. If the variable is undefined in the context, this method returns the given variable string. * @@ -208,16 +708,18 @@ public Object retraceVariable(String variable, int lineNumber) { * name of variable in context * @param lineNumber * current line number, for error reporting + * @param startPosition + * current line position, for error reporting * @return resolved value for variable */ - public Object resolveObject(String variable, int lineNumber) { + public Object resolveObject(String variable, int lineNumber, int startPosition) { if (StringUtils.isBlank(variable)) { return ""; } if (WhitespaceUtils.isQuoted(variable)) { - return WhitespaceUtils.unquote(variable); + return WhitespaceUtils.unquoteAndUnescape(variable); } else { - Object val = retraceVariable(variable, lineNumber); + Object val = retraceVariable(variable, lineNumber, startPosition); if (val == null) { return variable; } @@ -225,6 +727,10 @@ public Object resolveObject(String variable, int lineNumber) { } } + public Object resolveObject(String variable, int lineNumber) { + return resolveObject(variable, lineNumber, -1); + } + /** * Resolve a variable into a string value. If given a string literal (e.g. 'foo' or "foo"), this method returns the literal unquoted. If the variable is undefined in the context, this method returns the given variable string. * @@ -232,42 +738,93 @@ public Object resolveObject(String variable, int lineNumber) { * name of variable in context * @param lineNumber * current line number, for error reporting + * @param startPosition + * current line position, for error reporting * @return resolved value for variable */ + public String resolveString(String variable, int lineNumber, int startPosition) { + Object object = resolveObject(variable, lineNumber, startPosition); + return getAsString(object); + } + + public String getAsString(Object object) { + if (config.getLegacyOverrides().isUsePyishObjectMapper()) { + return PyishObjectMapper.getAsUnquotedPyishString(object); + } + return Objects.toString(object, ""); + } + public String resolveString(String variable, int lineNumber) { - return java.util.Objects.toString(resolveObject(variable, lineNumber), ""); + return resolveString(variable, lineNumber, -1); } public Context getContext() { return context; } + public String resolveResourceLocation(String location) { + return application + .getResourceLocator() + .getLocationResolver() + .map(resolver -> resolver.resolve(location, this)) + .orElse(location); + } + public String getResource(String resource) throws IOException { - return application.getResourceLocator().getString(resource, config.getCharset(), this); + return application + .getResourceLocator() + .getString(resource, config.getCharset(), this); } public JinjavaConfig getConfig() { return config; } + /** + * Resolve expression against current context, but does not add the expression to the set of resolved expressions. + * + * @param expression + * Jinja expression. + * @return Value of expression. + */ + public Object resolveELExpressionSilently(String expression) { + return expressionResolver.resolveExpressionSilently(expression); + } + /** * Resolve expression against current context. * - * @param expression Jinja expression. - * @param lineNumber Line number of expression. + * @param expression + * Jinja expression. + * @param lineNumber + * Line number of expression. * @return Value of expression. */ public Object resolveELExpression(String expression, int lineNumber) { this.lineNumber = lineNumber; - return expressionResolver.resolveExpression(expression); } + /** + * Resolve expression against current context. Also set the interpreter's position, + * useful for nodes that resolve multiple expressions such as a node using an IfTag and ElseTags. + * @param expression Jinja expression. + * @param lineNumber Line number of expression. + * @param position Start position of expression. + * @return Value of expression. + */ + public Object resolveELExpression(String expression, int lineNumber, int position) { + this.position = position; + return resolveELExpression(expression, lineNumber); + } + /** * Resolve property of bean. * - * @param object Bean. - * @param propertyName Name of property to resolve. + * @param object + * Bean. + * @param propertyName + * Name of property to resolve. * @return Value of property. */ public Object resolveProperty(Object object, String propertyName) { @@ -277,54 +834,248 @@ public Object resolveProperty(Object object, String propertyName) { /** * Resolve property of bean. * - * @param object Bean. - * @param propertyNames Names of properties to resolve recursively. + * @param object + * Bean. + * @param propertyNames + * Names of properties to resolve recursively. * @return Value of property. */ public Object resolveProperty(Object object, List propertyNames) { return expressionResolver.resolveProperty(object, propertyNames); } + /** + * Wrap an object in it's PyIsh equivalent + * + * @param object + * Bean. + * @return Wrapped bean. + */ + public Object wrap(Object object) { + return expressionResolver.wrap(object); + } + public int getLineNumber() { return lineNumber; } + public void setLineNumber(int lineNumber) { + this.lineNumber = lineNumber; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public BlockInfo getCurrentBlock() { + return currentBlock; + } + public void addError(TemplateError templateError) { - this.errors.add(templateError); + if (templateError == null) { + return; + } + ErrorHandlingStrategy errorHandlingStrategy = context.getErrorHandlingStrategy(); + ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy errorTypeHandlingStrategy = + templateError.getSeverity() == ErrorType.FATAL + ? errorHandlingStrategy.getFatalErrorStrategy() + : errorHandlingStrategy.getNonFatalErrorStrategy(); + switch (errorTypeHandlingStrategy) { + case IGNORE: + return; + case THROW_EXCEPTION: + throw new TemplateSyntaxException( + this, + templateError.getFieldName(), + templateError.getMessage() + ); + case ADD_ERROR: + default: // Checkstyle + // fix line numbers not matching up with source template + if (!context.getCurrentPathStack().isEmpty()) { + if ( + !templateError.getSourceTemplate().isPresent() && + context.getCurrentPathStack().peek().isPresent() + ) { + templateError.setMessage( + getWrappedErrorMessage( + context.getCurrentPathStack().peek().get(), + templateError + ) + ); + templateError.setSourceTemplate(context.getCurrentPathStack().peek().get()); + } + templateError.setStartPosition( + context.getCurrentPathStack().getTopStartPosition() + ); + templateError.setLineno(context.getCurrentPathStack().getTopLineNumber()); + } + + // Limit the number of errors and filter duplicates + if (errors.size() < MAX_ERROR_SIZE) { + templateError = templateError.withScopeDepth(scopeDepth); + int errorCode = templateError.hashCode(); + if (!errorSet.contains(errorCode)) { + this.errors.add(templateError); + this.errorSet.add(errorCode); + } + } + } } - public List getErrors() { - return errors; + public void removeLastError() { + if (!errors.isEmpty()) { + TemplateError error = errors.remove(errors.size() - 1); + errorSet.remove(error.hashCode()); + } } - private static final ThreadLocal> CURRENT_INTERPRETER = new ThreadLocal>() { - @Override - protected Stack initialValue() { - return new Stack<>(); + public Optional getLastError() { + return errors.isEmpty() + ? Optional.empty() + : Optional.of(errors.get(errors.size() - 1)); + } + + public int getScopeDepth() { + return scopeDepth; + } + + /** + Use {@link #addAllChildErrors(String, Collection)} instead to fix error line numbers + */ + @Deprecated + public void addAllErrors(Collection other) { + if (errors.size() >= MAX_ERROR_SIZE) { + return; + } + other.stream().limit(MAX_ERROR_SIZE - errors.size()).forEach(this::addError); + } + + public void addAllChildErrors( + String childTemplateName, + Collection childErrors + ) { + if (errors.size() >= MAX_ERROR_SIZE) { + return; } - }; + + childErrors + .stream() + .limit(MAX_ERROR_SIZE - errors.size()) + .forEach(error -> { + if (!error.getSourceTemplate().isPresent()) { + error.setMessage(getWrappedErrorMessage(childTemplateName, error)); + error.setSourceTemplate(childTemplateName); + } + error.setStartPosition(this.getPosition()); + error.setLineno(this.getLineNumber()); + this.addError(error); + }); + } + + // We cannot just remove this, other projects may depend on it. + public List getErrors() { + return getErrorsCopy(); + } + + // Explicitly indicate this returns a copy of the errors list. + public List getErrorsCopy() { + return Lists.newArrayList(errors); + } + + private static final ThreadLocal> CURRENT_INTERPRETER = + ThreadLocal.withInitial(Stack::new); public static JinjavaInterpreter getCurrent() { - if (CURRENT_INTERPRETER.get().isEmpty()) { + Stack stack = CURRENT_INTERPRETER.get(); + if (stack.isEmpty()) { return null; } + return stack.peek(); + } + + public static Optional getCurrentMaybe() { + return Optional.ofNullable(getCurrent()); + } - return CURRENT_INTERPRETER.get().peek(); + public static AutoCloseableSupplier closeablePushCurrent( + JinjavaInterpreter interpreter + ) { + Stack stack = CURRENT_INTERPRETER.get(); + stack.push(interpreter); + return AutoCloseableSupplier.of(() -> interpreter, i -> stack.pop()); } + @Deprecated public static void pushCurrent(JinjavaInterpreter interpreter) { CURRENT_INTERPRETER.get().push(interpreter); } + @Deprecated public static void popCurrent() { if (!CURRENT_INTERPRETER.get().isEmpty()) { CURRENT_INTERPRETER.get().pop(); } } - public static final String INSERT_FLAG = "'IS\"INSERT"; + public void startRender(String name) { + RenderTimings renderTimings = (RenderTimings) getContext().get("request"); + if (renderTimings != null) { + renderTimings.start(this, name); + } + } + + public void endRender(String name) { + RenderTimings renderTimings = (RenderTimings) getContext().get("request"); + if (renderTimings != null) { + renderTimings.end(this, name); + } + } + + public void endRender(String name, Map data) { + RenderTimings renderTimings = (RenderTimings) getContext().get("request"); + if (renderTimings != null) { + renderTimings.end(this, name, data); + } + } - public static final String BLOCK_STUB_START = "___bl0ck___~"; - public static final String BLOCK_STUB_END = "~"; + private String getWrappedErrorMessage( + String childTemplateName, + TemplateError templateError + ) { + String severity = templateError.getSeverity() == ErrorType.WARNING + ? "Warning" + : "Error"; + String lineNumber = templateError.getLineno() > 0 + ? String.format(" on line %d", templateError.getLineno()) + : ""; + + if (Strings.isNullOrEmpty(templateError.getMessage())) { + return String.format( + "Unknown %s in file `%s`%s", + severity.toLowerCase(), + childTemplateName, + lineNumber + ); + } else { + return String.format( + "%s in `%s`%s: %s", + severity, + childTemplateName, + lineNumber, + templateError.getMessage() + ); + } + } + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(ExtendedParser.INTERPRETER); + } } diff --git a/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreterFactory.java b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreterFactory.java new file mode 100644 index 000000000..6eb5db393 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreterFactory.java @@ -0,0 +1,21 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; + +public class JinjavaInterpreterFactory implements InterpreterFactory { + + @Override + public JinjavaInterpreter newInstance(JinjavaInterpreter orig) { + return new JinjavaInterpreter(orig); + } + + @Override + public JinjavaInterpreter newInstance( + Jinjava application, + Context context, + JinjavaConfig renderConfig + ) { + return new JinjavaInterpreter(application, context, renderConfig); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/LazyExpression.java b/src/main/java/com/hubspot/jinjava/interpret/LazyExpression.java new file mode 100644 index 000000000..ee248f05a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/LazyExpression.java @@ -0,0 +1,53 @@ +package com.hubspot.jinjava.interpret; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.function.Supplier; + +public class LazyExpression implements Supplier { + + private final Supplier supplier; + private final String image; + private final Memoization memoization; + private Object jsonValue = null; + + public enum Memoization { + ON, + OFF, + } + + protected LazyExpression(Supplier supplier, String image, Memoization memoization) { + this.supplier = supplier; + this.image = image; + this.memoization = memoization; + } + + public static LazyExpression of(Supplier supplier, String image) { + return new LazyExpression(supplier, image, Memoization.ON); + } + + public static LazyExpression of( + Supplier supplier, + String image, + Memoization memoization + ) { + return new LazyExpression(supplier, image, memoization); + } + + @Override + public Object get() { + if (jsonValue == null || memoization == Memoization.OFF) { + jsonValue = supplier.get(); + } + return jsonValue; + } + + @Override + public String toString() { + return image; + } + + @JsonValue + public Object getJsonValue() { + return jsonValue == null ? "" : jsonValue; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/LazyReference.java b/src/main/java/com/hubspot/jinjava/interpret/LazyReference.java new file mode 100644 index 000000000..06ffe2398 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/LazyReference.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.interpret; + +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; + +public class LazyReference extends LazyExpression implements PyishSerializable { + + private String referenceKey; + + protected LazyReference(Context referenceContext, String referenceKey) { + super(() -> referenceContext.get(referenceKey), "", Memoization.ON); + get(); + this.referenceKey = referenceKey; + } + + public static LazyReference of(Context referenceContext, String referenceKey) { + return new LazyReference(referenceContext, referenceKey); + } + + public String getReferenceKey() { + return referenceKey; + } + + public void setReferenceKey(String referenceKey) { + this.referenceKey = referenceKey; + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(getReferenceKey()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/MacroTagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/MacroTagCycleException.java new file mode 100644 index 000000000..f71bfb6a2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/MacroTagCycleException.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +public class MacroTagCycleException extends TagCycleException { + + private static final long serialVersionUID = -7552850581260771832L; + + public MacroTagCycleException(String path, int lineNumber, int startPosition) { + super("Macro", path, lineNumber, startPosition); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/MetaContextVariables.java b/src/main/java/com/hubspot/jinjava/interpret/MetaContextVariables.java new file mode 100644 index 000000000..2630a41ac --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/MetaContextVariables.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; +import java.util.Objects; + +@Beta +public class MetaContextVariables { + + public static final String TEMPORARY_META_CONTEXT_PREFIX = "__temp_meta_"; + private static final String TEMPORARY_IMPORT_ALIAS_PREFIX = + TEMPORARY_META_CONTEXT_PREFIX + "import_alias_"; + + private static final String TEMPORARY_IMPORT_ALIAS_FORMAT = + TEMPORARY_IMPORT_ALIAS_PREFIX + "%d__"; + private static final String TEMP_CURRENT_PATH_PREFIX = + TEMPORARY_META_CONTEXT_PREFIX + "current_path_"; + private static final String TEMP_CURRENT_PATH_FORMAT = + TEMP_CURRENT_PATH_PREFIX + "%d__"; + + public static boolean isMetaContextVariable(String varName, Context context) { + if (isTemporaryMetaContextVariable(varName)) { + return true; + } + return ( + context.getMetaContextVariables().contains(varName) && + !context.getNonMetaContextVariables().contains(varName) + ); + } + + private static boolean isTemporaryMetaContextVariable(String varName) { + return varName.startsWith(TEMPORARY_META_CONTEXT_PREFIX); + } + + public static boolean isTemporaryImportAlias(String varName) { + // This is just faster than checking a regex + return varName.startsWith(TEMPORARY_IMPORT_ALIAS_PREFIX); + } + + public static String getTemporaryImportAlias(String fullAlias) { + return String.format( + TEMPORARY_IMPORT_ALIAS_FORMAT, + Math.abs(Objects.hashCode(fullAlias)) + ); + } + + public static String getTemporaryCurrentPathVarName(String newPath) { + return String.format( + TEMP_CURRENT_PATH_FORMAT, + Math.abs(Objects.hash(newPath, TEMPORARY_META_CONTEXT_PREFIX) >> 1) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/MissingEndTagException.java b/src/main/java/com/hubspot/jinjava/interpret/MissingEndTagException.java index 76b5fb422..bdf154417 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/MissingEndTagException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/MissingEndTagException.java @@ -3,13 +3,27 @@ import org.apache.commons.lang3.StringUtils; public class MissingEndTagException extends TemplateSyntaxException { + private static final long serialVersionUID = 1L; private final String endTag; private final String startDefinition; - public MissingEndTagException(String endTag, String startDefintion, int lineNumber) { - super(startDefintion, "Missing end tag: " + endTag + " for tag defined as: " + StringUtils.abbreviate(startDefintion, 255), lineNumber); + public MissingEndTagException( + String endTag, + String startDefintion, + int lineNumber, + int startPosition + ) { + super( + startDefintion, + "Missing end tag: " + + endTag + + " for tag defined as: " + + StringUtils.abbreviate(startDefintion, 255), + lineNumber, + startPosition + ); this.endTag = endTag; this.startDefinition = startDefintion; } @@ -21,5 +35,4 @@ public String getEndTag() { public String getStartDefinition() { return startDefinition; } - } diff --git a/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java b/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java new file mode 100644 index 000000000..d4bd77eb9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java @@ -0,0 +1,14 @@ +package com.hubspot.jinjava.interpret; + +/** + * Exception thrown when `continue` or `break` is called outside of a loop + */ +public class NotInLoopException extends InterpretException { + + public static final String MESSAGE_PREFIX = "`"; + public static final String MESSAGE_SUFFIX = "` called while not in a for loop"; + + public NotInLoopException(String tagName) { + super(MESSAGE_PREFIX + tagName + MESSAGE_SUFFIX); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/NullValue.java b/src/main/java/com/hubspot/jinjava/interpret/NullValue.java new file mode 100644 index 000000000..bd7f115ce --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/NullValue.java @@ -0,0 +1,50 @@ +package com.hubspot.jinjava.interpret; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.hubspot.jinjava.interpret.NullValue.NullValueSerializer; +import java.io.IOException; + +/** + * Marker object of a `null` value. A null value in the map is usually considered + * the key does not exist. For example map = {"a": null}, if map.get("a") == null, + * we treat it as the there is not key "a" in the map. + */ +@JsonSerialize(using = NullValueSerializer.class) +public final class NullValue { + + public static final NullValue INSTANCE = new NullValue(); + + public static class NullValueSerializer extends StdSerializer { + + public NullValueSerializer() { + this(null); + } + + protected NullValueSerializer(Class t) { + super(t); + } + + @Override + public void serialize( + NullValue value, + JsonGenerator jgen, + SerializerProvider provider + ) throws IOException { + jgen.writeNull(); + } + } + + private NullValue() {} + + public static NullValue instance() { + return INSTANCE; + } + + @Override + public String toString() { + return "null"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/OneTimeReconstructible.java b/src/main/java/com/hubspot/jinjava/interpret/OneTimeReconstructible.java new file mode 100644 index 000000000..197a7050c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/OneTimeReconstructible.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.interpret; + +public interface OneTimeReconstructible extends DeferredValue { + boolean isReconstructed(); + + void setReconstructed(boolean reconstructed); +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/OutputTooBigException.java b/src/main/java/com/hubspot/jinjava/interpret/OutputTooBigException.java new file mode 100644 index 000000000..d13e59530 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/OutputTooBigException.java @@ -0,0 +1,21 @@ +package com.hubspot.jinjava.interpret; + +public class OutputTooBigException extends RuntimeException { + + private long maxSize; + private final long size; + + public OutputTooBigException(long maxSize, long size) { + this.maxSize = maxSize; + this.size = size; + } + + @Override + public String getMessage() { + return String.format( + "%d byte output rendered, over limit of %d bytes", + size, + maxSize + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/PartiallyDeferredValue.java b/src/main/java/com/hubspot/jinjava/interpret/PartiallyDeferredValue.java new file mode 100644 index 000000000..73e355146 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/PartiallyDeferredValue.java @@ -0,0 +1,10 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; + +/** + * An interface for a type of DeferredValue that as a whole is not deferred, + * but certain attributes or methods within it are deferred. + */ +@Beta +public interface PartiallyDeferredValue extends DeferredValue {} diff --git a/src/main/java/com/hubspot/jinjava/interpret/RenderResult.java b/src/main/java/com/hubspot/jinjava/interpret/RenderResult.java index 09ec31078..4c2d1fd7a 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/RenderResult.java +++ b/src/main/java/com/hubspot/jinjava/interpret/RenderResult.java @@ -1,9 +1,9 @@ package com.hubspot.jinjava.interpret; +import com.google.common.collect.ImmutableList; import java.util.Collections; import java.util.List; - -import com.google.common.collect.ImmutableList; +import java.util.Optional; public class RenderResult { @@ -17,10 +17,19 @@ public RenderResult(String output, Context context, List errors) this.errors = errors; } - public RenderResult(TemplateError fromException, Context context, List errors) { + public RenderResult( + TemplateError fromException, + Context context, + List errors + ) { this.output = ""; this.context = context; - this.errors = ImmutableList. builder().add(fromException).addAll(errors).build(); + this.errors = + ImmutableList + .builder() + .add(fromException) + .addAll(Optional.ofNullable(errors).orElse(Collections.emptyList())) + .build(); } public RenderResult(String result) { @@ -48,5 +57,4 @@ public String getOutput() { public RenderResult withOutput(String newOutput) { return new RenderResult(newOutput, getContext(), getErrors()); } - } diff --git a/src/main/java/com/hubspot/jinjava/interpret/RenderTimings.java b/src/main/java/com/hubspot/jinjava/interpret/RenderTimings.java new file mode 100644 index 000000000..2e4300289 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/RenderTimings.java @@ -0,0 +1,9 @@ +package com.hubspot.jinjava.interpret; + +import java.util.Map; + +public interface RenderTimings { + void start(JinjavaInterpreter interpreter, String name); + void end(JinjavaInterpreter interpreter, String name); + void end(JinjavaInterpreter interpreter, String name, Map data); +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/RevertibleObject.java b/src/main/java/com/hubspot/jinjava/interpret/RevertibleObject.java new file mode 100644 index 000000000..acee72604 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/RevertibleObject.java @@ -0,0 +1,29 @@ +package com.hubspot.jinjava.interpret; + +import com.google.common.annotations.Beta; +import java.util.Optional; + +@Beta +public class RevertibleObject { + + private final Object hashCode; + private final Optional pyishString; + + public RevertibleObject(Object hashCode) { + this.hashCode = hashCode; + pyishString = Optional.empty(); + } + + public RevertibleObject(Object hashCode, String pyishString) { + this.hashCode = hashCode; + this.pyishString = Optional.ofNullable(pyishString); + } + + public Object getHashCode() { + return hashCode; + } + + public Optional getPyishString() { + return pyishString; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/TagCycleException.java b/src/main/java/com/hubspot/jinjava/interpret/TagCycleException.java new file mode 100644 index 000000000..a27248840 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/TagCycleException.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.interpret; + +import java.util.Optional; + +public class TagCycleException extends TemplateStateException { + + private static final long serialVersionUID = -3058494056577268723L; + + private final String path; + private final String tagName; + + public TagCycleException( + String tagName, + String path, + int lineNumber, + int startPosition + ) { + super(tagName + " tag cycle for '" + path + "'", lineNumber, startPosition); + this.path = path; + this.tagName = tagName; + } + + public String getPath() { + return path; + } + + public String getTagName() { + return tagName; + } + + public static TagCycleException create( + Class clazz, + String path, + Optional lineNumber, + Optional startPosition + ) { + final Integer line = lineNumber.orElse(-1); + final Integer position = startPosition.orElse(-1); + + if (clazz != null) { + if (clazz.equals(ExtendsTagCycleException.class)) { + return new ExtendsTagCycleException(path, line, position); + } else if (clazz.equals(ImportTagCycleException.class)) { + return new ImportTagCycleException(path, line, position); + } else if (clazz.equals(IncludeTagCycleException.class)) { + return new IncludeTagCycleException(path, line, position); + } else if (clazz.equals(MacroTagCycleException.class)) { + return new MacroTagCycleException(path, line, position); + } else if (clazz.equals(FromTagCycleException.class)) { + return new FromTagCycleException(path, line, position); + } + } + + return new TagCycleException("", path, line, position); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/TemplateError.java b/src/main/java/com/hubspot/jinjava/interpret/TemplateError.java index f10474cd7..495f617ed 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/TemplateError.java +++ b/src/main/java/com/hubspot/jinjava/interpret/TemplateError.java @@ -1,53 +1,258 @@ package com.hubspot.jinjava.interpret; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.interpret.errorcategory.TemplateErrorCategory; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.regex.Pattern; - import org.apache.commons.lang3.exception.ExceptionUtils; -import com.google.common.base.Objects; - public class TemplateError { + + private static final Pattern GENERIC_TOSTRING_PATTERN = Pattern.compile( + "@[0-9a-z]{4,}$" + ); + private static final int MAX_STRING_LENGTH = 1024; + public enum ErrorType { - FATAL, WARNING + FATAL, + WARNING, } public enum ErrorReason { - SYNTAX_ERROR, UNKNOWN, BAD_URL, EXCEPTION, MISSING, OTHER + SYNTAX_ERROR, + UNKNOWN, + BAD_URL, + EXCEPTION, + MISSING, + DISABLED, + INVALID_ARGUMENT, + INVALID_INPUT, + OUTPUT_TOO_BIG, + OVER_LIMIT, + COLLECTION_TOO_BIG, + OTHER, + } + + public enum ErrorItem { + TEMPLATE, + TOKEN, + TAG, + FUNCTION, + PROPERTY, + FILTER, + EXPRESSION_TEST, + OTHER, } private final ErrorType severity; private final ErrorReason reason; - private final String message; + private final ErrorItem item; + private String message; private final String fieldName; - private final Integer lineno; + private int lineno; + private int startPosition; + private final TemplateErrorCategory category; + private final Map categoryErrors; + private String sourceTemplate; + + private int scopeDepth = 1; private final Exception exception; + public TemplateError withScopeDepth(int scopeDepth) { + TemplateError error = new TemplateError( + getSeverity(), + getReason(), + getItem(), + getMessage(), + getFieldName(), + getLineno(), + getStartPosition(), + getException(), + getCategory(), + getCategoryErrors(), + scopeDepth + ); + getSourceTemplate().ifPresent(error::setSourceTemplate); + return error; + } + public static TemplateError fromSyntaxError(InterpretException ex) { - return new TemplateError(ErrorType.FATAL, ErrorReason.SYNTAX_ERROR, ExceptionUtils.getMessage(ex), null, ex.getLineNumber(), ex); + return new TemplateError( + ErrorType.FATAL, + ErrorReason.SYNTAX_ERROR, + ErrorItem.OTHER, + ExceptionUtils.getMessage(ex), + null, + ex.getLineNumber(), + ex.getStartPosition(), + ex + ); } public static TemplateError fromException(TemplateSyntaxException ex) { - return new TemplateError(ErrorType.FATAL, ErrorReason.SYNTAX_ERROR, ExceptionUtils.getMessage(ex), null, ex.getLineNumber(), ex); + String fieldName = (ex instanceof UnknownTagException) + ? ((UnknownTagException) ex).getTag() + : ex.getCode(); + return new TemplateError( + ErrorType.FATAL, + ErrorReason.SYNTAX_ERROR, + ErrorItem.OTHER, + ex.getMessage(), + fieldName, + ex.getLineNumber(), + ex.getStartPosition(), + ex + ); + } + + public static TemplateError fromInvalidArgumentException(InvalidArgumentException ex) { + return new TemplateError( + ErrorType.FATAL, + ErrorReason.INVALID_ARGUMENT, + ErrorItem.PROPERTY, + ex.getMessage(), + ex.getName(), + ex.getLineNumber(), + ex.getStartPosition(), + ex + ); + } + + public static TemplateError fromInvalidInputException(InvalidInputException ex) { + return new TemplateError( + ErrorType.FATAL, + ErrorReason.INVALID_INPUT, + ErrorItem.PROPERTY, + ex.getMessage(), + ex.getName(), + ex.getLineNumber(), + ex.getStartPosition(), + ex + ); + } + + public static TemplateError fromMissingFilterArgException(InvalidArgumentException ex) { + return new TemplateError( + ErrorType.WARNING, + ErrorReason.INVALID_ARGUMENT, + ErrorItem.FILTER, + ex.getMessage(), + ex.getName(), + ex.getLineNumber(), + ex.getStartPosition(), + ex + ); } public static TemplateError fromException(Exception ex) { int lineNumber = -1; + int startPosition = -1; if (ex instanceof InterpretException) { lineNumber = ((InterpretException) ex).getLineNumber(); + startPosition = ((InterpretException) ex).getStartPosition(); } - return new TemplateError(ErrorType.FATAL, ErrorReason.EXCEPTION, ExceptionUtils.getMessage(ex), null, lineNumber, ex); + return new TemplateError( + ErrorType.FATAL, + ErrorReason.EXCEPTION, + ErrorItem.OTHER, + ExceptionUtils.getMessage(ex), + null, + lineNumber, + startPosition, + ex, + BasicTemplateErrorCategory.UNKNOWN, + ImmutableMap.of() + ); + } + + public static TemplateError fromOutputTooBigException(Exception ex) { + return new TemplateError( + ErrorType.FATAL, + ErrorReason.OUTPUT_TOO_BIG, + ErrorItem.OTHER, + ExceptionUtils.getMessage(ex), + null, + -1, + -1, + ex, + BasicTemplateErrorCategory.UNKNOWN, + ImmutableMap.of() + ); + } + + public static TemplateError fromException( + Exception ex, + int lineNumber, + int startPosition + ) { + return new TemplateError( + ErrorType.FATAL, + ErrorReason.EXCEPTION, + ErrorItem.OTHER, + ExceptionUtils.getMessage(ex), + null, + lineNumber, + startPosition, + ex + ); } public static TemplateError fromException(Exception ex, int lineNumber) { - return new TemplateError(ErrorType.FATAL, ErrorReason.EXCEPTION, ExceptionUtils.getMessage(ex), null, lineNumber, ex); + return new TemplateError( + ErrorType.FATAL, + ErrorReason.EXCEPTION, + ErrorItem.OTHER, + ExceptionUtils.getMessage(ex), + null, + lineNumber, + -1, + ex + ); + } + + public static TemplateError fromUnknownProperty( + Object base, + String variable, + int lineNumber + ) { + return fromUnknownProperty(base, variable, lineNumber, -1); } - public static TemplateError fromUnknownProperty(Object base, String variable, int lineNumber) { - return new TemplateError(ErrorType.WARNING, ErrorReason.UNKNOWN, String.format("Cannot resolve property '%s' in '%s'", variable, friendlyObjectToString(base)), - variable, lineNumber, null); + public static TemplateError fromUnknownProperty( + Object base, + String variable, + int lineNumber, + int startPosition + ) { + return new TemplateError( + ErrorType.WARNING, + ErrorReason.UNKNOWN, + ErrorItem.PROPERTY, + String.format( + "Cannot resolve property '%s' in '%s'", + variable, + friendlyObjectToString(base) + ), + variable, + lineNumber, + startPosition, + null, + BasicTemplateErrorCategory.UNKNOWN_PROPERTY, + ImmutableMap.of( + "property", + variable, + "lineNumber", + String.valueOf(lineNumber), + "startPosition", + String.valueOf(startPosition) + ) + ); } private static String friendlyObjectToString(Object o) { @@ -57,6 +262,10 @@ private static String friendlyObjectToString(Object o) { String s = o.toString(); + if (s.length() > MAX_STRING_LENGTH) { + s = s.substring(0, MAX_STRING_LENGTH) + "..."; + } + if (!GENERIC_TOSTRING_PATTERN.matcher(s).find()) { return s; } @@ -65,17 +274,141 @@ private static String friendlyObjectToString(Object o) { return c.getSimpleName(); } - // java.lang.Object@7852e922 - private static final Pattern GENERIC_TOSTRING_PATTERN = Pattern.compile("@[0-9a-z]{4,}$"); + public TemplateError( + ErrorType severity, + ErrorReason reason, + ErrorItem item, + String message, + String fieldName, + int lineno, + Exception exception + ) { + this.severity = severity; + this.reason = reason; + this.item = item; + this.message = message; + this.fieldName = fieldName; + this.lineno = lineno; + this.startPosition = -1; + this.exception = exception; + this.category = BasicTemplateErrorCategory.UNKNOWN; + this.categoryErrors = null; + } + + public TemplateError( + ErrorType severity, + ErrorReason reason, + ErrorItem item, + String message, + String fieldName, + int lineno, + int startPosition, + Exception exception + ) { + this.severity = severity; + this.reason = reason; + this.item = item; + this.message = message; + this.fieldName = fieldName; + this.lineno = lineno; + this.startPosition = startPosition; + this.exception = exception; + this.category = BasicTemplateErrorCategory.UNKNOWN; + this.categoryErrors = null; + } + + public TemplateError( + ErrorType severity, + ErrorReason reason, + ErrorItem item, + String message, + String fieldName, + int lineno, + int startPosition, + Exception exception, + TemplateErrorCategory category, + Map categoryErrors, + int scopeDepth + ) { + this.severity = severity; + this.reason = reason; + this.item = item; + this.message = message; + this.fieldName = fieldName; + this.lineno = lineno; + this.startPosition = startPosition; + this.exception = exception; + this.category = category; + this.categoryErrors = categoryErrors; + this.scopeDepth = scopeDepth; + } - public TemplateError(ErrorType severity, ErrorReason reason, String message, - String fieldName, Integer lineno, Exception exception) { + public TemplateError( + ErrorType severity, + ErrorReason reason, + ErrorItem item, + String message, + String fieldName, + int lineno, + Exception exception, + TemplateErrorCategory category, + Map categoryErrors + ) { this.severity = severity; this.reason = reason; + this.item = item; this.message = message; this.fieldName = fieldName; this.lineno = lineno; + this.startPosition = -1; this.exception = exception; + this.category = category; + this.categoryErrors = categoryErrors; + } + + public TemplateError( + ErrorType severity, + ErrorReason reason, + ErrorItem item, + String message, + String fieldName, + int lineno, + int startPosition, + Exception exception, + TemplateErrorCategory category, + Map categoryErrors + ) { + this.severity = severity; + this.reason = reason; + this.item = item; + this.message = message; + this.fieldName = fieldName; + this.lineno = lineno; + this.startPosition = startPosition; + this.exception = exception; + this.category = category; + this.categoryErrors = categoryErrors; + } + + public TemplateError( + ErrorType severity, + ErrorReason reason, + String message, + String fieldName, + int lineno, + int startPosition, + Exception exception + ) { + this.severity = severity; + this.reason = reason; + this.item = ErrorItem.OTHER; + this.message = message; + this.fieldName = fieldName; + this.lineno = lineno; + this.startPosition = startPosition; + this.exception = exception; + this.category = BasicTemplateErrorCategory.UNKNOWN; + this.categoryErrors = null; } public ErrorType getSeverity() { @@ -86,35 +419,143 @@ public ErrorReason getReason() { return reason; } + public ErrorItem getItem() { + return item; + } + public String getMessage() { return message; } + public void setMessage(String message) { + this.message = message; + } + public String getFieldName() { return fieldName; } - public Integer getLineno() { + public int getLineno() { return lineno; } + public void setLineno(int lineno) { + this.lineno = lineno; + } + + public int getStartPosition() { + return startPosition; + } + + public void setStartPosition(int startPosition) { + this.startPosition = startPosition; + } + public Exception getException() { return exception; } + public TemplateErrorCategory getCategory() { + return category; + } + + public Map getCategoryErrors() { + return categoryErrors; + } + + public int getScopeDepth() { + return scopeDepth; + } + + public Optional getSourceTemplate() { + return Optional.ofNullable(sourceTemplate); + } + + public void setSourceTemplate(String sourceTemplate) { + this.sourceTemplate = sourceTemplate; + } + public TemplateError serializable() { - return new TemplateError(severity, reason, message, fieldName, lineno, null); + return new TemplateError( + severity, + reason, + item, + message, + fieldName, + lineno, + startPosition, + null, + category, + categoryErrors, + scopeDepth + ); } @Override public String toString() { - return Objects.toStringHelper(this) - .add("severity", severity) - .add("reason", reason) - .add("message", message) - .add("fieldName", fieldName) - .add("lineno", lineno) - .toString(); + return ( + "TemplateError{" + + "severity=" + + severity + + ", reason=" + + reason + + ", item=" + + item + + ", message='" + + message + + '\'' + + ", fieldName='" + + fieldName + + '\'' + + ", lineno=" + + lineno + + ", startPosition=" + + startPosition + + ", scopeDepth=" + + scopeDepth + + ", category=" + + category + + ", categoryErrors=" + + categoryErrors + + '}' + ); } + @Override + public boolean equals(Object o) { + if (!(o instanceof TemplateError)) { + return false; + } + + TemplateError other = (TemplateError) o; + return ( + Objects.equals(severity, other.severity) && + Objects.equals(reason, other.reason) && + Objects.equals(item, other.item) && + Objects.equals(message, other.message) && + Objects.equals(fieldName, other.fieldName) && + Objects.equals(lineno, other.lineno) && + Objects.equals(startPosition, other.startPosition) && + Objects.equals(category, other.category) && + Objects.equals(categoryErrors, other.categoryErrors) && + Objects.equals(scopeDepth, other.scopeDepth) + ); + } + + @Override + public int hashCode() { + return Objects.hash( + severity, + reason, + item, + message, + fieldName, + lineno, + startPosition, + category, + categoryErrors, + scopeDepth, + sourceTemplate + ); + } } diff --git a/src/main/java/com/hubspot/jinjava/interpret/TemplateStateException.java b/src/main/java/com/hubspot/jinjava/interpret/TemplateStateException.java new file mode 100644 index 000000000..e98aaeb32 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/TemplateStateException.java @@ -0,0 +1,35 @@ +package com.hubspot.jinjava.interpret; + +public class TemplateStateException extends InterpretException { + + private static final long serialVersionUID = 426925445445430522L; + + public TemplateStateException(String msg) { + super(msg); + } + + public TemplateStateException(String msg, Throwable e) { + super(msg, e); + } + + public TemplateStateException(String msg, int lineNumber, int startPosition) { + super(msg, lineNumber, startPosition); + } + + public TemplateStateException(String msg, int lineNumber) { + super(msg, lineNumber, -1); + } + + public TemplateStateException( + String msg, + Throwable e, + int lineNumber, + int startPosition + ) { + super(msg, e, lineNumber, startPosition); + } + + public TemplateStateException(String msg, Throwable e, int lineNumber) { + super(msg, e, lineNumber, -1); + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/TemplateSyntaxException.java b/src/main/java/com/hubspot/jinjava/interpret/TemplateSyntaxException.java index 99de097ad..1ac4ff731 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/TemplateSyntaxException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/TemplateSyntaxException.java @@ -1,22 +1,62 @@ package com.hubspot.jinjava.interpret; public class TemplateSyntaxException extends InterpretException { + private static final long serialVersionUID = 1L; private final String code; - public TemplateSyntaxException(String code, String message, int lineNumber) { - super("Syntax error in '" + code + "': " + message, lineNumber); + @Deprecated + public TemplateSyntaxException( + String code, + String message, + int lineNumber, + int startPosition + ) { + super( + String.format("Syntax error in '%s': %s", code, message), + lineNumber, + startPosition + ); this.code = code; } - public TemplateSyntaxException(String code, String message, int lineNumber, Throwable t) { - super(message, t, lineNumber); + @Deprecated + public TemplateSyntaxException(String code, String message, int lineNumber) { + this(code, message, lineNumber, -1); + } + + @Deprecated + public TemplateSyntaxException( + String code, + String message, + int lineNumber, + int startPosition, + Throwable t + ) { + super(message, t, lineNumber, startPosition); this.code = code; } + @Deprecated + public TemplateSyntaxException( + String code, + String message, + int lineNumber, + Throwable t + ) { + this(code, message, lineNumber, -1, t); + } + + public TemplateSyntaxException( + JinjavaInterpreter interpreter, + String code, + String message + ) { + this(code, message, interpreter.getLineNumber(), interpreter.getPosition()); + } + public String getCode() { return code; } - } diff --git a/src/main/java/com/hubspot/jinjava/interpret/UnexpectedTokenException.java b/src/main/java/com/hubspot/jinjava/interpret/UnexpectedTokenException.java index 68815d036..1d1ae33a3 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/UnexpectedTokenException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/UnexpectedTokenException.java @@ -1,17 +1,17 @@ package com.hubspot.jinjava.interpret; public class UnexpectedTokenException extends TemplateSyntaxException { + private static final long serialVersionUID = 1L; private final String token; - public UnexpectedTokenException(String token, int lineNumber) { - super(token, "Unexpected token: " + token, lineNumber); + public UnexpectedTokenException(String token, int lineNumber, int startPosition) { + super(token, "Unexpected token: " + token, lineNumber, startPosition); this.token = token; } public String getToken() { return token; } - } diff --git a/src/main/java/com/hubspot/jinjava/interpret/UnknownTagException.java b/src/main/java/com/hubspot/jinjava/interpret/UnknownTagException.java index 5c075f436..a07621ce7 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/UnknownTagException.java +++ b/src/main/java/com/hubspot/jinjava/interpret/UnknownTagException.java @@ -3,27 +3,45 @@ import com.hubspot.jinjava.tree.parse.TagToken; public class UnknownTagException extends TemplateSyntaxException { + private static final long serialVersionUID = 1L; private final String tag; - private final String defintion; - - public UnknownTagException(String tag, String defintion, int lineNumber) { - super(defintion, "Unknown tag: " + tag, lineNumber); + private final String definition; + + public UnknownTagException( + String tag, + String definition, + int lineNumber, + int startPosition + ) { + super(definition, "Unknown tag: " + tag, lineNumber, startPosition); this.tag = tag; - this.defintion = defintion; + this.definition = definition; } public UnknownTagException(TagToken tagToken) { - this(tagToken.getTagName(), tagToken.getImage(), tagToken.getLineNumber()); + this( + tagToken.getRawTagName(), + tagToken.getImage(), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); } public String getTag() { return tag; } + /** + * @deprecated use correct spelling + */ + @Deprecated public String getDefintion() { - return defintion; + return definition; } + public String getDefinition() { + return definition; + } } diff --git a/src/main/java/com/hubspot/jinjava/interpret/UnknownTokenException.java b/src/main/java/com/hubspot/jinjava/interpret/UnknownTokenException.java new file mode 100644 index 000000000..243473826 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/UnknownTokenException.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava.interpret; + +public class UnknownTokenException extends InterpretException { + + private static final long serialVersionUID = -388757722051666198L; + private final String token; + + public UnknownTokenException(String token, int lineNumber, int startPosition) { + super("Unknown token found: " + token.trim(), lineNumber, startPosition); + this.token = token; + } + + public String getToken() { + return token; + } +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/errorcategory/BasicTemplateErrorCategory.java b/src/main/java/com/hubspot/jinjava/interpret/errorcategory/BasicTemplateErrorCategory.java new file mode 100644 index 000000000..e17932a44 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/errorcategory/BasicTemplateErrorCategory.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.interpret.errorcategory; + +public enum BasicTemplateErrorCategory implements TemplateErrorCategory { + CYCLE_DETECTED, + IMPORT_CYCLE_DETECTED, + INCLUDE_CYCLE_DETECTED, + FROM_CYCLE_DETECTED, + UNKNOWN, + UNKNOWN_DATE, + UNKNOWN_LOCALE, + UNKNOWN_PROPERTY, +} diff --git a/src/main/java/com/hubspot/jinjava/interpret/errorcategory/TemplateErrorCategory.java b/src/main/java/com/hubspot/jinjava/interpret/errorcategory/TemplateErrorCategory.java new file mode 100644 index 000000000..de852f036 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/errorcategory/TemplateErrorCategory.java @@ -0,0 +1,3 @@ +package com.hubspot.jinjava.interpret.errorcategory; + +public interface TemplateErrorCategory {} diff --git a/src/main/java/com/hubspot/jinjava/lib/Importable.java b/src/main/java/com/hubspot/jinjava/lib/Importable.java index 6267148bd..357eccc33 100644 --- a/src/main/java/com/hubspot/jinjava/lib/Importable.java +++ b/src/main/java/com/hubspot/jinjava/lib/Importable.java @@ -16,7 +16,5 @@ package com.hubspot.jinjava.lib; public interface Importable { - String getName(); - } diff --git a/src/main/java/com/hubspot/jinjava/lib/SimpleLibrary.java b/src/main/java/com/hubspot/jinjava/lib/SimpleLibrary.java index e7368708c..93afea000 100644 --- a/src/main/java/com/hubspot/jinjava/lib/SimpleLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/SimpleLibrary.java @@ -15,23 +15,31 @@ **********************************************************************/ package com.hubspot.jinjava.lib; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.interpret.DisabledException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; - -import org.apache.commons.lang3.StringUtils; - -import com.google.common.base.Throwables; +import java.util.Set; +import java.util.stream.Collectors; public abstract class SimpleLibrary { - private Map lib = new HashMap(); + private Map lib = new HashMap<>(); + private Set disabled = new HashSet<>(); protected SimpleLibrary(boolean registerDefaults) { + this(registerDefaults, null); + } + + protected SimpleLibrary(boolean registerDefaults, Set disabled) { + if (disabled != null) { + this.disabled = ImmutableSet.copyOf(disabled); + } if (registerDefaults) { registerDefaults(); } @@ -40,7 +48,11 @@ protected SimpleLibrary(boolean registerDefaults) { protected abstract void registerDefaults(); public T fetch(String item) { - return lib.get(StringUtils.lowerCase(item)); + if (disabled.contains(item)) { + throw new DisabledException(item); + } + + return lib.get(item); } @SafeVarargs @@ -56,7 +68,8 @@ public final List registerClasses(Class... itemClass) { return instances; } catch (Exception e) { - throw Throwables.propagate(e); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); } } @@ -65,12 +78,16 @@ public void register(T obj) { } public void register(String name, T obj) { - lib.put(name, obj); - ENGINE_LOG.debug(getClass().getSimpleName() + ": Registered " + obj.getName()); + if (!disabled.contains(obj.getName())) { + lib.put(name, obj); + } } public Collection entries() { - return lib.values(); + return lib + .values() + .stream() + .filter(t -> !disabled.contains(t.getName())) + .collect(Collectors.toSet()); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/expression/DefaultExpressionStrategy.java b/src/main/java/com/hubspot/jinjava/lib/expression/DefaultExpressionStrategy.java new file mode 100644 index 000000000..96140b745 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/expression/DefaultExpressionStrategy.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.lib.expression; + +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.FeatureActivationStrategy; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.EscapeFilter; +import com.hubspot.jinjava.objects.SafeString; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.util.Logging; +import org.apache.commons.lang3.StringUtils; + +public class DefaultExpressionStrategy implements ExpressionStrategy { + + private static final long serialVersionUID = 436239440273704843L; + public static final String ECHO_UNDEFINED = BuiltInFeatures.ECHO_UNDEFINED; + + public RenderedOutputNode interpretOutput( + ExpressionToken master, + JinjavaInterpreter interpreter + ) { + Object var = interpreter.resolveELExpression( + master.getExpr(), + master.getLineNumber() + ); + + final FeatureActivationStrategy feat = interpreter + .getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.ECHO_UNDEFINED); + + if (var == null && feat.isActive(interpreter.getContext())) { + return new RenderedOutputNode(master.getImage()); + } + + String result = interpreter.getAsString(var); + + if (interpreter.getConfig().isNestedInterpretationEnabled()) { + if ( + !StringUtils.equals(result, master.getImage()) && + (StringUtils.contains(result, master.getSymbols().getExpressionStart()) || + StringUtils.contains(result, master.getSymbols().getExpressionStartWithTag())) + ) { + try { + result = interpreter.renderFlat(result); + } catch (Exception e) { + Logging.ENGINE_LOG.warn("Error rendering variable node result", e); + } + } + } + + if (interpreter.getContext().isAutoEscape() && !(var instanceof SafeString)) { + result = EscapeFilter.escapeHtmlEntities(result); + } + + return new RenderedOutputNode(result); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategy.java b/src/main/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategy.java new file mode 100644 index 000000000..35a184f52 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategy.java @@ -0,0 +1,141 @@ +package com.hubspot.jinjava.lib.expression; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.ErrorHandlingStrategy; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.filter.EscapeFilter; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.Logging; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerExpressionStrategy implements ExpressionStrategy { + + private static final long serialVersionUID = -6792345439237764193L; + + @Override + public RenderedOutputNode interpretOutput( + ExpressionToken master, + JinjavaInterpreter interpreter + ) { + return new RenderedOutputNode(eagerResolveExpression(master, interpreter)); + } + + private String eagerResolveExpression( + ExpressionToken master, + JinjavaInterpreter interpreter + ) { + interpreter.getContext().checkNumberOfDeferredTokens(); + EagerExecutionResult eagerExecutionResult = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResolver.resolveExpression(master.getExpr(), interpreter), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .withPartialMacroEvaluation( + interpreter.getConfig().isNestedInterpretationEnabled() + ) + .build() + ); + + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if ( + !eagerExecutionResult.getResult().isFullyResolved() || + interpreter.getContext().isDeferredExecutionMode() + ) { + prefixToPreserveState.putAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + if (eagerExecutionResult.getResult().isFullyResolved()) { + String result = eagerExecutionResult.getResult().toString(true); + return ( + prefixToPreserveState.toString() + postProcessResult(master, result, interpreter) + ); + } + + String deferredExpressionImage = wrapInExpression( + eagerExecutionResult.getResult().toString(), + interpreter + ); + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExecutionResult.getResult().getDeferredWords(), + interpreter + ); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(deferredExpressionImage, master) + .addUsedDeferredWords(eagerExecutionResult.getResult().getDeferredWords()) + .build() + ) + ); + // There is only a preserving prefix because it couldn't be entirely evaluated. + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + prefixToPreserveState.toString() + deferredExpressionImage, + interpreter + ); + } + + public static String postProcessResult( + ExpressionToken master, + String result, + JinjavaInterpreter interpreter + ) { + if ( + !StringUtils.equals(result, master.getImage()) && + (StringUtils.contains(result, master.getSymbols().getExpressionStart()) || + StringUtils.contains(result, master.getSymbols().getExpressionStartWithTag())) + ) { + if (interpreter.getConfig().isNestedInterpretationEnabled()) { + try { + try ( + TemporaryValueClosable c = interpreter + .getContext() + .withErrorHandlingStrategy(ErrorHandlingStrategy.throwAll()) + ) { + interpreter.parse(result); + } + try { + result = interpreter.renderFlat(result); + } catch (Exception e) { + Logging.ENGINE_LOG.warn("Error rendering variable node result", e); + } + } catch (TemplateSyntaxException ignored) {} + } else { + result = EagerReconstructionUtils.wrapInRawIfNeeded(result, interpreter); + } + } + + if (interpreter.getContext().isAutoEscape()) { + result = EscapeFilter.escapeHtmlEntities(result); + } + return result; + } + + private static String wrapInExpression(String output, JinjavaInterpreter interpreter) { + JinjavaConfig config = interpreter.getConfig(); + return String.format( + "%s %s %s", + config.getTokenScannerSymbols().getExpressionStart(), + output, + config.getTokenScannerSymbols().getExpressionEnd() + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/expression/ExpressionStrategy.java b/src/main/java/com/hubspot/jinjava/lib/expression/ExpressionStrategy.java new file mode 100644 index 000000000..bcb5edc9a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/expression/ExpressionStrategy.java @@ -0,0 +1,13 @@ +package com.hubspot.jinjava.lib.expression; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import java.io.Serializable; + +public interface ExpressionStrategy extends Serializable { + RenderedOutputNode interpretOutput( + ExpressionToken master, + JinjavaInterpreter interpreter + ); +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/CollectionExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/CollectionExpTest.java new file mode 100644 index 000000000..d2190bca7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/CollectionExpTest.java @@ -0,0 +1,11 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.el.ext.CollectionMembershipOperator; + +public abstract class CollectionExpTest implements ExpTest { + + protected static final TruthyTypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + protected static final CollectionMembershipOperator COLLECTION_MEMBERSHIP_OPERATOR = + new CollectionMembershipOperator(); +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTest.java index 6f2679330..0dcae972a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTest.java @@ -15,7 +15,13 @@ * */ public interface ExpTest extends Importable { - boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args); + default boolean evaluateNegated( + Object var, + JinjavaInterpreter interpreter, + Object... args + ) { + return !evaluate(var, interpreter, args); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java index 848320606..b14dc8451 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java @@ -1,32 +1,59 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.lib.SimpleLibrary; +import java.util.Set; public class ExpTestLibrary extends SimpleLibrary { - public ExpTestLibrary(boolean registerDefaults) { - super(registerDefaults); + public ExpTestLibrary(boolean registerDefaults, Set disabled) { + super(registerDefaults, disabled); } @Override protected void registerDefaults() { registerClasses( - IsDefinedExpTest.class, - IsDivisibleByExpTest.class, - IsEqualToExpTest.class, - IsEvenExpTest.class, - IsIterableExpTest.class, - IsLowerExpTest.class, - IsMappingExpTest.class, - IsNoneExpTest.class, - IsNumberExpTest.class, - IsOddExpTest.class, - IsSameAsExpTest.class, - IsSequenceExpTest.class, - IsStringExpTest.class, - IsTruthyExpTest.class, - IsUndefinedExpTest.class, - IsUpperExpTest.class); + IsDefinedExpTest.class, + IsDivisibleByExpTest.class, + IsEqualToExpTest.class, + IsEqExpTest.class, + IsEqualsSymbolExpTest.class, + IsNeExpTest.class, + IsNotEqualToSymbolExpTest.class, + IsLtTest.class, + IsLessThanExpTest.class, + IsLessThanSymbolExpTest.class, + IsLeTest.class, + IsLessThanOrEqualToSymbolExpTest.class, + IsGtTest.class, + IsGreaterThanExpTest.class, + IsGreaterThanSymbolExpTest.class, + IsGeTest.class, + IsGreaterThanOrEqualToSymbolExpTest.class, + IsEvenExpTest.class, + IsIterableExpTest.class, + IsLowerExpTest.class, + IsMappingExpTest.class, + IsNoneExpTest.class, + IsNumberExpTest.class, + IsOddExpTest.class, + IsSameAsExpTest.class, + IsSequenceExpTest.class, + IsBooleanExpTest.class, + IsIntegerExpTest.class, + IsFloatExpTest.class, + IsStringExpTest.class, + IsStringContainingExpTest.class, + IsStringStartingWithExpTest.class, + IsTrueExpTest.class, + IsFalseExpTest.class, + IsTruthyExpTest.class, + IsUndefinedExpTest.class, + IsUpperExpTest.class, + IsContainingAllExpTest.class, + IsContainingExpTest.class, + IsInExpTest.class, + IsWithinExpTest.class + ); } public ExpTest getExpTest(String name) { @@ -36,5 +63,4 @@ public ExpTest getExpTest(String name) { public void addExpTest(ExpTest expTest) { register(expTest); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsBooleanExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsBooleanExpTest.java new file mode 100644 index 000000000..daa4c5574 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsBooleanExpTest.java @@ -0,0 +1,30 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Return true if object is a boolean (in a strict sense, not in its ability to evaluate to a truthy expression)", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if true is boolean %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsBooleanExpTest implements ExpTest { + + @Override + public String getName() { + return "boolean"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return var instanceof Boolean; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTest.java new file mode 100644 index 000000000..479ca55a9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTest.java @@ -0,0 +1,46 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.ForLoop; +import com.hubspot.jinjava.util.ObjectIterator; + +@JinjavaDoc( + value = "Returns true if a list contains all values in a second list", + input = @JinjavaParam(value = "list", type = "list", required = true), + params = @JinjavaParam( + value = "list_two", + type = "list", + desc = "The second list to check if every element is in the first list", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3] is containingall [2, 3] }}") } +) +public class IsContainingAllExpTest extends CollectionExpTest { + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (null == var || args.length == 0 || args[0] == null) { + return false; + } + + ForLoop loop = ObjectIterator.getLoop(args[0]); + while (loop.hasNext()) { + Object matchValue = loop.next(); + if ( + !(Boolean) COLLECTION_MEMBERSHIP_OPERATOR.apply(TYPE_CONVERTER, matchValue, var) + ) { + return false; + } + } + + return true; + } + + @Override + public String getName() { + return "containingall"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTest.java new file mode 100644 index 000000000..ada662025 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTest.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Returns true if a list contains a value", + input = @JinjavaParam(value = "list", type = "list", required = true), + params = @JinjavaParam( + value = "value", + type = "object", + desc = "The value to check is in the list", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3] is containing 2 }}") } +) +public class IsContainingExpTest extends CollectionExpTest { + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args == null || args.length == 0) { + return false; + } + + return (Boolean) COLLECTION_MEMBERSHIP_OPERATOR.apply(TYPE_CONVERTER, args[0], var); + } + + @Override + public String getName() { + return "containing"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsDefinedExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsDefinedExpTest.java index b990187ba..3b850322f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsDefinedExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsDefinedExpTest.java @@ -1,17 +1,21 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the variable is defined", - snippets = { - @JinjavaSnippet( - code = "{% if variable is defined %}\n" + - "\n" + - "{% endif %}") - }) + value = "Return true if the variable is defined", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is defined %}\n" + + "\n" + + "{% endif %}" + ), + } +) public class IsDefinedExpTest implements ExpTest { @Override @@ -23,5 +27,4 @@ public String getName() { public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return var != null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsDivisibleByExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsDivisibleByExpTest.java index b42fa4fbb..1beb4a0f8 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsDivisibleByExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsDivisibleByExpTest.java @@ -3,21 +3,30 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; -@JinjavaDoc(value = "Check if a variable is divisible by a number", - params = { - @JinjavaParam(value = "num", type = "number", desc = "The number to check whether a number is divisble by") - }, - snippets = { - @JinjavaSnippet( - code = "{% if variable is divisbleby 5 %}\n" + - " \n" + - "{% else %}\n" + - " \n" + - "{% endif %}") - }) +@JinjavaDoc( + value = "Returns true if a variable is divisible by a number", + input = @JinjavaParam(value = "num", type = "number", required = true), + params = @JinjavaParam( + value = "divisor", + type = "number", + desc = "The number to check whether a number is divisible by", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% if variable is divisibleby 5 %}\n" + + " \n" + + "{% else %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsDivisibleByExpTest implements ExpTest { @Override @@ -33,12 +42,62 @@ public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... ar if (!Number.class.isAssignableFrom(var.getClass())) { return false; } + Number freeFormDividend = (Number) var; + if ( + Math.ceil(freeFormDividend.doubleValue()) != + Math.floor(freeFormDividend.doubleValue()) + ) { + return false; + } + int dividend = freeFormDividend.intValue(); - if (args.length == 0 || args[0] == null || !Number.class.isAssignableFrom(args[0].getClass())) { - throw new InterpretException(getName() + " test requires a numeric argument"); + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (name of expression test to filter by)" + ); } - return ((Number) var).intValue() % ((Number) args[0]).intValue() == 0; - } + if (args[0] == null) { + return false; + } + if (!Number.class.isAssignableFrom(args[0].getClass())) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + args[0].toString() + ); + } + + Number freeFormDivisor = (Number) args[0]; + if ( + Math.floor(freeFormDivisor.doubleValue()) != + Math.ceil(freeFormDivisor.doubleValue()) + ) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NON_ZERO_NUMBER, + 0, + args[0].toString() + ); + } + + int divisor = ((Number) args[0]).intValue(); + if (divisor == 0) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NON_ZERO_NUMBER, + 0, + args[0].toString() + ); + } + + return (dividend % divisor) == 0; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqExpTest.java new file mode 100644 index 000000000..8f57b6c2e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "equalto") +public class IsEqExpTest extends IsEqualToExpTest { + + @Override + public String getName() { + return "eq"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTest.java index 25a3079f1..d3095b139 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTest.java @@ -1,42 +1,56 @@ package com.hubspot.jinjava.lib.exptest; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.el.TruthyTypeConverter; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; @JinjavaDoc( - value = "Check if an object has the same value as another object", - params = { - @JinjavaParam(value = "other", type = "object", desc = "Another object to check equality against") - }, - snippets = { - @JinjavaSnippet( - code = "{% if foo.expression is equalto 42 %}\n" + - " the foo attribute evaluates to the constant 42\n" + - "{% endif %}\n"), - @JinjavaSnippet( - desc = "Usage with the selectattr filter", - code = "{{ users|selectattr(\"email\", \"equalto\", \"foo@bar.invalid\") }}"), - }) + value = "Returns true if an object has the same value as another object", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to check equality against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is equalto 42 %}\n" + + " the foo attribute evaluates to the constant 42\n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"email\", \"equalto\", \"foo@bar.invalid\") }}" + ), + } +) public class IsEqualToExpTest implements ExpTest { + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + @Override public String getName() { return "equalto"; } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { if (args.length == 0) { - throw new InterpretException(getName() + " test requires 1 argument"); + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to check equality against)" + ); } - return Objects.equals(var, args[0]); + return BooleanOperations.eq(TYPE_CONVERTER, var, args[0]); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualsSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualsSymbolExpTest.java new file mode 100644 index 000000000..2add76eaa --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEqualsSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "equalto") +public class IsEqualsSymbolExpTest extends IsEqualToExpTest { + + @Override + public String getName() { + return "=="; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEvenExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEvenExpTest.java index 3ddebcd4e..707ffb875 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsEvenExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsEvenExpTest.java @@ -1,19 +1,23 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the value is even", - snippets = { - @JinjavaSnippet( - code = "{% if variable is even %}\n" + - " \n" + - "{% else %}\n" + - " \n" + - "{% endif %}") - }) + value = "Returns true if the value is even", + input = @JinjavaParam(value = "num", type = "number", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is even %}\n" + + " \n" + + "{% else %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsEvenExpTest implements ExpTest { @Override @@ -22,13 +26,11 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { if (var == null || !Number.class.isAssignableFrom(var.getClass())) { return false; } return ((Number) var).intValue() % 2 == 0; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsFalseExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsFalseExpTest.java new file mode 100644 index 000000000..e3c15fd0c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsFalseExpTest.java @@ -0,0 +1,30 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Return true if object is a boolean and false", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if false is false %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsFalseExpTest implements ExpTest { + + @Override + public String getName() { + return "false"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return var instanceof Boolean && !(Boolean) var; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTest.java new file mode 100644 index 000000000..7b0af240a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTest.java @@ -0,0 +1,35 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; + +@JinjavaDoc( + value = "Return true if object is a float", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if num is float %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsFloatExpTest implements ExpTest { + + @Override + public String getName() { + return "float"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return ( + var instanceof Double || + var instanceof Float || + (var instanceof BigDecimal && ((BigDecimal) var).scale() > 0) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsGeTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGeTest.java new file mode 100644 index 000000000..dbcf6279b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGeTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; + +@JinjavaDoc( + value = "Returns true if the first object's value is greater than or equal to the second object's value", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to compare against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is ge 42 %}\n" + + " the foo attribute evaluates to the constant 42\n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"num\", \"ge\", \"2\") }}" + ), + } +) +public class IsGeTest implements ExpTest { + + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + @Override + public String getName() { + return "ge"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to compare against)" + ); + } + + return BooleanOperations.ge(TYPE_CONVERTER, var, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanExpTest.java new file mode 100644 index 000000000..8e9198f23 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "gt") +public class IsGreaterThanExpTest extends IsGtTest { + + @Override + public String getName() { + return "greaterthan"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanOrEqualToSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanOrEqualToSymbolExpTest.java new file mode 100644 index 000000000..8d69b8507 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanOrEqualToSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "ge") +public class IsGreaterThanOrEqualToSymbolExpTest extends IsGeTest { + + @Override + public String getName() { + return ">="; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanSymbolExpTest.java new file mode 100644 index 000000000..0b0c0f12f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGreaterThanSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "gt") +public class IsGreaterThanSymbolExpTest extends IsGtTest { + + @Override + public String getName() { + return ">"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsGtTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGtTest.java new file mode 100644 index 000000000..c88dcd481 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsGtTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; + +@JinjavaDoc( + value = "Returns true if the first object's value is strictly greater than the second", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to compare against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is gt 42 %}\n" + + " the foo attribute evaluates to the constant 43\n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"num\", \"gt\", \"2\") }}" + ), + } +) +public class IsGtTest implements ExpTest { + + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + @Override + public String getName() { + return "gt"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to compare against)" + ); + } + + return BooleanOperations.gt(TYPE_CONVERTER, var, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsInExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsInExpTest.java new file mode 100644 index 000000000..f40d2b37e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsInExpTest.java @@ -0,0 +1,50 @@ +package com.hubspot.jinjava.lib.exptest; + +import static com.hubspot.jinjava.lib.exptest.IsIterableExpTest.isIterable; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Returns true if value is contained in the iterable", + input = @JinjavaParam(value = "value", type = "object", required = true), + params = @JinjavaParam( + value = "list", + type = "object", + desc = "The iterable to check for the value", + required = true + ), + snippets = { + @JinjavaSnippet(code = "{{ 2 is in [1, 2, 3] }}"), + @JinjavaSnippet(code = "{{ 'b' is in 'abc' }}"), + @JinjavaSnippet(code = "{{ 'k2' is in {'k1':'v1', 'k2':'v2'} }}"), + } +) +public class IsInExpTest extends CollectionExpTest { + + @Override + public String getName() { + return "in"; + } + + @Override + public boolean evaluate(Object value, JinjavaInterpreter interpreter, Object... args) { + if (args == null || args.length == 0) { + return false; + } + if (!isIterable(args[0])) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NOT_ITERABLE, + 0, + args[0] + ); + } + return (Boolean) COLLECTION_MEMBERSHIP_OPERATOR.apply(TYPE_CONVERTER, value, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTest.java new file mode 100644 index 000000000..1500abad3 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTest.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.math.BigInteger; + +@JinjavaDoc( + value = "Return true if object is an integer or long", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if num is integer %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsIntegerExpTest implements ExpTest { + + @Override + public String getName() { + return "integer"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return ( + var instanceof Byte || + var instanceof Short || + var instanceof Integer || + var instanceof Long || + var instanceof BigInteger || + (var instanceof BigDecimal && ((BigDecimal) var).scale() == 0) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTest.java index e810e8bd5..61b34face 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTest.java @@ -1,17 +1,21 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the object is iterable (sequence, dict, etc)", - snippets = { - @JinjavaSnippet( - code = "{% if variable is iterable %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if the object is iterable (sequence, dict, etc)", + input = @JinjavaParam(value = "object", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is iterable %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsIterableExpTest implements ExpTest { @Override @@ -21,7 +25,13 @@ public String getName() { @Override public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { - return var != null && (var.getClass().isArray() || Iterable.class.isAssignableFrom(var.getClass())); + return isIterable(var); } + static boolean isIterable(Object ob) { + return ( + ob != null && + (ob.getClass().isArray() || Iterable.class.isAssignableFrom(ob.getClass())) + ); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLeTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLeTest.java new file mode 100644 index 000000000..e1423cf10 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLeTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; + +@JinjavaDoc( + value = "Returns true if the first object's value is less than or equal to the second object's value", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to compare against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is le 42 %}\n" + + " the foo attribute evaluates to the constant 42\n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"num\", \"le\", \"2\") }}" + ), + } +) +public class IsLeTest implements ExpTest { + + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + @Override + public String getName() { + return "le"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to compare against)" + ); + } + + return BooleanOperations.le(TYPE_CONVERTER, var, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanExpTest.java new file mode 100644 index 000000000..32f6aea99 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "lt") +public class IsLessThanExpTest extends IsLtTest { + + @Override + public String getName() { + return "lessthan"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanOrEqualToSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanOrEqualToSymbolExpTest.java new file mode 100644 index 000000000..b9c19a5c5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanOrEqualToSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "le") +public class IsLessThanOrEqualToSymbolExpTest extends IsLeTest { + + @Override + public String getName() { + return "<="; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanSymbolExpTest.java new file mode 100644 index 000000000..16dec5f63 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLessThanSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "lt") +public class IsLessThanSymbolExpTest extends IsLtTest { + + @Override + public String getName() { + return "<"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLowerExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLowerExpTest.java index aa2775db1..810067a49 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLowerExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLowerExpTest.java @@ -1,19 +1,22 @@ package com.hubspot.jinjava.lib.exptest; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Return true if the given string is all lowercased", - snippets = { - @JinjavaSnippet( - code = "{% if variable is lower %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if the given string is all lowercase", + input = @JinjavaParam(value = "string", type = "string", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is lower %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsLowerExpTest implements ExpTest { @Override @@ -22,13 +25,11 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { if (var == null || !(var instanceof String)) { return false; } return StringUtils.isAllLowerCase((String) var); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsLtTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLtTest.java new file mode 100644 index 000000000..ebcc184ce --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsLtTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; + +@JinjavaDoc( + value = "Returns true if the first object's value is strictly less than the second", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to compare against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is lt 42 %}\n" + + " the foo attribute evaluates to the constant 41\n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"num\", \"lt\", \"2\") }}" + ), + } +) +public class IsLtTest implements ExpTest { + + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + @Override + public String getName() { + return "lt"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to compare against)" + ); + } + + return BooleanOperations.lt(TYPE_CONVERTER, var, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsMappingExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsMappingExpTest.java index c262644b9..5cd697544 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsMappingExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsMappingExpTest.java @@ -1,19 +1,22 @@ package com.hubspot.jinjava.lib.exptest; -import java.util.Map; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Map; @JinjavaDoc( - value = "Return true if the given object is a dict", - snippets = { - @JinjavaSnippet( - code = "{% if variable is mapping %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if the given object is a dict", + input = @JinjavaParam(value = "object", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is mapping %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsMappingExpTest implements ExpTest { @Override @@ -25,5 +28,4 @@ public String getName() { public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return var != null && Map.class.isAssignableFrom(var.getClass()); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNeExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNeExpTest.java new file mode 100644 index 000000000..057382094 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNeExpTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.BooleanOperations; +import de.odysseus.el.misc.TypeConverter; + +@JinjavaDoc( + value = "Returns true if an object has the different value from another object", + input = @JinjavaParam(value = "first", type = "object", required = true), + params = { + @JinjavaParam( + value = "other", + type = "object", + desc = "Another object to check inequality against", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if foo.expression is ne 42 %}\n" + + " the foo attribute evaluates to the constant 43 \n" + + "{% endif %}\n" + ), + @JinjavaSnippet( + desc = "Usage with the selectattr filter", + code = "{{ users|selectattr(\"email\", \"ne\", \"foo@bar.invalid\") }}" + ), + } +) +public class IsNeExpTest implements ExpTest { + + private static final TypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); + + @Override + public String getName() { + return "ne"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to check inequality against)" + ); + } + + return BooleanOperations.ne(TYPE_CONVERTER, var, args[0]); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNoneExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNoneExpTest.java index ebfd1fe69..93ff43f1e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNoneExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNoneExpTest.java @@ -1,17 +1,21 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the given object is null / none", - snippets = { - @JinjavaSnippet( - code = "{% unless variable is none %}\n" + - " \n" + - "{% endunless %}") - }) + value = "Return true if the given object is null / none", + input = @JinjavaParam(value = "object", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% unless variable is none %}\n" + + " \n" + + "{% endunless %}" + ), + } +) public class IsNoneExpTest implements ExpTest { @Override @@ -20,9 +24,7 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return var == null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNotEqualToSymbolExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNotEqualToSymbolExpTest.java new file mode 100644 index 000000000..5a715d65a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNotEqualToSymbolExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "ne") +public class IsNotEqualToSymbolExpTest extends IsNeExpTest { + + @Override + public String getName() { + return "!="; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNumberExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNumberExpTest.java index 1316100bf..a024a8c06 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsNumberExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsNumberExpTest.java @@ -1,19 +1,23 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the object is a number", - snippets = { - @JinjavaSnippet( - code = "{% if variable is number %}\n" + - " {{ my_var * 1000000 }}\n" + - "{% else %}\n" + - " The variable is not a number.\n" + - "{% endif %}") - }) + value = "Return true if the object is a number", + input = @JinjavaParam(value = "object", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is number %}\n" + + " {{ my_var * 1000000 }}\n" + + "{% else %}\n" + + " The variable is not a number.\n" + + "{% endif %}" + ), + } +) public class IsNumberExpTest implements ExpTest { @Override @@ -22,9 +26,7 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return var != null && Number.class.isAssignableFrom(var.getClass()); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsOddExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsOddExpTest.java index 5df14e94f..287d15374 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsOddExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsOddExpTest.java @@ -1,19 +1,23 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the object is an odd number", - snippets = { - @JinjavaSnippet( - code = "{% if variable is odd %}\n" + - " \n" + - "{% else %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if a number is an odd number", + input = @JinjavaParam(value = "num", type = "number", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is odd %}\n" + + " \n" + + "{% else %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsOddExpTest implements ExpTest { @Override @@ -22,13 +26,11 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { if (var == null || !Number.class.isAssignableFrom(var.getClass())) { return false; } return ((Number) var).intValue() % 2 != 0; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsSameAsExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsSameAsExpTest.java index 4481ff04b..c357210d0 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsSameAsExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsSameAsExpTest.java @@ -3,17 +3,26 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; -@JinjavaDoc(value = "Return true if variable is pointing at same object as other variable", - params = @JinjavaParam(value = "other", type = "object", desc = "A second object to check the variables value against"), - snippets = { - @JinjavaSnippet( - code = "{% if var_one is sameas var_two %}\n" + - " \n" + - "{% endif %}") - }) +@JinjavaDoc( + value = "Return true if variable is pointing at same object as other variable", + input = @JinjavaParam(value = "object", type = "object", required = true), + params = @JinjavaParam( + value = "other", + type = "object", + desc = "A second object to check the variables value against", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% if var_one is sameas var_two %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsSameAsExpTest implements ExpTest { @Override @@ -22,13 +31,15 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { if (args.length == 0) { - throw new InterpretException(getName() + " test requires 1 argument"); + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other object to check the variables value against)" + ); } return var == args[0]; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsSequenceExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsSequenceExpTest.java index 24847a32e..c48c5e133 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsSequenceExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsSequenceExpTest.java @@ -1,17 +1,23 @@ package com.hubspot.jinjava.lib.exptest; +import static com.hubspot.jinjava.lib.exptest.IsIterableExpTest.isIterable; + import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if the variable is a sequence. Sequences are variables that are iterable.", - snippets = { - @JinjavaSnippet( - code = "{% if variable is sequence %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if the variable is a sequence. Sequences are variables that are iterable.", + input = @JinjavaParam(value = "object", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is sequence %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsSequenceExpTest implements ExpTest { @Override @@ -21,7 +27,6 @@ public String getName() { @Override public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { - return var != null && (var.getClass().isArray() || Iterable.class.isAssignableFrom(var.getClass())); + return isIterable(var); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTest.java new file mode 100644 index 000000000..01d17d707 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTest.java @@ -0,0 +1,53 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; + +@JinjavaDoc( + value = "Return true if object is a string which contains a specified other string", + input = @JinjavaParam(value = "string", type = "string", required = true), + params = @JinjavaParam( + value = "check", + type = "string", + desc = "A second string to check is contained in the first string", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% if variable is string_containing 'foo' %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsStringContainingExpTest extends IsStringExpTest { + + @Override + public String getName() { + return super.getName() + "_containing"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (!super.evaluate(var, interpreter, args)) { + return false; + } + + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other string to compare to)" + ); + } + + if (args[0] == null) { + return false; + } + + return ((String) var).contains(args[0].toString()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringExpTest.java index 154b64cc8..b9cb56cdb 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringExpTest.java @@ -1,17 +1,22 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.SafeString; @JinjavaDoc( - value = "Return true if object is a string", - snippets = { - @JinjavaSnippet( - code = "{% if variable is string %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if object is a string", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is string %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsStringExpTest implements ExpTest { @Override @@ -20,9 +25,7 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { - return var != null && var instanceof String; + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return var != null && (var instanceof String || var instanceof SafeString); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTest.java new file mode 100644 index 000000000..8a7b527c0 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTest.java @@ -0,0 +1,53 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; + +@JinjavaDoc( + value = "Return true if object is a string which starts with a specified other string", + input = @JinjavaParam(value = "value", type = "string", required = true), + params = @JinjavaParam( + value = "check", + type = "string", + desc = "A second string to check is the start of the first string", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% if variable is string_startingwith 'foo' %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsStringStartingWithExpTest extends IsStringExpTest { + + @Override + public String getName() { + return super.getName() + "_startingwith"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (!super.evaluate(var, interpreter, args)) { + return false; + } + + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (other string to compare to)" + ); + } + + if (args[0] == null) { + return false; + } + + return ((String) var).startsWith(args[0].toString()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsTrueExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsTrueExpTest.java new file mode 100644 index 000000000..31338ee4e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsTrueExpTest.java @@ -0,0 +1,30 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Return true if object is a boolean and true", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if false is true %}\n" + + " \n" + + "{% endif %}" + ), + } +) +public class IsTrueExpTest implements ExpTest { + + @Override + public String getName() { + return "true"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + return var instanceof Boolean && (Boolean) var; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsTruthyExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsTruthyExpTest.java index 1cb611925..8a7d09875 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsTruthyExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsTruthyExpTest.java @@ -1,18 +1,22 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ObjectTruthValue; @JinjavaDoc( - value = "Return true if object is 'truthy'", - snippets = { - @JinjavaSnippet( - code = "{% if variable is truthy %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if object is 'truthy'", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is truthy %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsTruthyExpTest implements ExpTest { @Override @@ -24,5 +28,4 @@ public String getName() { public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return ObjectTruthValue.evaluate(var); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsUndefinedExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsUndefinedExpTest.java index da7274607..f1cb78e4b 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsUndefinedExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsUndefinedExpTest.java @@ -1,17 +1,21 @@ package com.hubspot.jinjava.lib.exptest; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return true if object is undefined", - snippets = { - @JinjavaSnippet( - code = "{% if variable is undefined %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if object is undefined", + input = @JinjavaParam(value = "value", type = "object", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is undefined %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsUndefinedExpTest implements ExpTest { @Override @@ -20,9 +24,7 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { return var == null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTest.java index f113b081a..61d8246fe 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTest.java @@ -1,19 +1,23 @@ package com.hubspot.jinjava.lib.exptest; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.SafeString; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Return true if string is all uppercased", - snippets = { - @JinjavaSnippet( - code = "{% if variable is upper %}\n" + - " \n" + - "{% endif %}") - }) + value = "Return true if string is all uppercased", + input = @JinjavaParam(value = "value", type = "string", required = true), + snippets = { + @JinjavaSnippet( + code = "{% if variable is upper %}\n" + + " \n" + + "{% endif %}" + ), + } +) public class IsUpperExpTest implements ExpTest { @Override @@ -22,13 +26,11 @@ public String getName() { } @Override - public boolean evaluate(Object var, JinjavaInterpreter interpreter, - Object... args) { - if (var == null || !(var instanceof String)) { + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (!(var instanceof String || var instanceof SafeString)) { return false; } - return StringUtils.isAllUpperCase((String) var); + return StringUtils.isAllUpperCase(var.toString()); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTest.java new file mode 100644 index 000000000..0c5e58b91 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTest.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; + +@JinjavaDoc(value = "", aliasOf = "in") +public class IsWithinExpTest extends IsInExpTest { + + @Override + public String getName() { + return "within"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AbsFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AbsFilter.java index 076578e1c..578a92407 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/AbsFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AbsFilter.java @@ -1,80 +1,81 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.math.BigInteger; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.math.BigInteger; @JinjavaDoc( - value = "Return the absolute value of the argument.", - params = { - @JinjavaParam(value = "number", type = "number", desc = "The number that you want to get the absolute value of") - }, - snippets = { - @JinjavaSnippet( - code = "{% set my_number = -53 %}\n" + - "{{ my_number|abs }}") - }) + value = "Return the absolute value of the argument.", + input = @JinjavaParam( + value = "number", + type = "number", + desc = "The number that you want to get the absolute value of", + required = true + ), + snippets = { + @JinjavaSnippet(code = "{% set my_number = -53 %}\n" + "{{ my_number|abs }}"), + } +) public class AbsFilter implements Filter { @Override public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { + if (object == null) { + return null; + } + if (object instanceof Integer) { return Math.abs((Integer) object); - } - if (object instanceof Float) { + } else if (object instanceof Float) { return Math.abs((Float) object); - } - if (object instanceof Long) { + } else if (object instanceof Long) { return Math.abs((Long) object); - } - if (object instanceof Short) { + } else if (object instanceof Short) { return Math.abs((Short) object); - } - if (object instanceof Double) { + } else if (object instanceof Double) { return Math.abs((Double) object); - } - if (object instanceof BigDecimal) { + } else if (object instanceof BigDecimal) { return ((BigDecimal) object).abs(); - } - if (object instanceof BigInteger) { + } else if (object instanceof BigInteger) { return ((BigInteger) object).abs(); - } - if (object instanceof Byte) { + } else if (object instanceof Byte) { return Math.abs((Byte) object); - } - if (object instanceof String) { + } else { try { - return new BigDecimal((String) object).abs(); + return new BigDecimal(object.toString()).abs(); } catch (Exception e) { - throw new InterpretException(object + " can't be dealed with abs filter", e); + throw new InvalidInputException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + object.toString() + ); } } - return object; } @Override public String getName() { return "abs"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AbstractFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AbstractFilter.java new file mode 100644 index 000000000..b39344c05 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AbstractFilter.java @@ -0,0 +1,250 @@ +/********************************************************************** + Copyright (c) 2020 HubSpot 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.hubspot.jinjava.lib.filter; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +/*** + * Filter base that uses Filter Jinjavadoc to construct named argument parameters. + * Only filters that specify name, type and defaults correctly should use this as a base + * + * @see JinjavaDoc + * @see JinjavaParam + */ +public abstract class AbstractFilter implements Filter { + + private static final Map> NAMED_ARGUMENTS_CACHE = + new ConcurrentHashMap<>(); + private static final Map> DEFAULT_VALUES_CACHE = + new ConcurrentHashMap<>(); + + private final Map namedArguments; + private final Map defaultValues; + + public AbstractFilter() { + namedArguments = + NAMED_ARGUMENTS_CACHE.computeIfAbsent(getClass(), cls -> initNamedArguments()); + defaultValues = + DEFAULT_VALUES_CACHE.computeIfAbsent(getClass(), cls -> initDefaultValues()); + } + + abstract Object filter( + Object var, + JinjavaInterpreter interpreter, + Map parsedArgs + ); + + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return filter(var, interpreter, args, Collections.emptyMap()); + } + + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + //Set defaults + Map namedArgs = new HashMap<>(defaultValues); + + //Process named params + for (Map.Entry passedNamedArgEntry : kwargs.entrySet()) { + String argName = passedNamedArgEntry.getKey(); + Object argValue = passedNamedArgEntry.getValue(); + int argPosition = getNamedArgumentPosition(argName); + if (argPosition == -1) { + throw new InvalidInputException( + interpreter, + "INVALID_ARG_NAME", + String.format( + "Argument named '%s' is invalid for filter %s", + argName, + getName() + ) + ); + } + namedArgs.put(argName, argValue); + } + + //Process indexed params, as declared + for (int i = 0; i < args.length; i++) { + Object arg = args[i]; + String argName = getIndexedArgumentName(i); + if (argName == null) { + throw new InvalidInputException( + interpreter, + "INVALID_ARG_NAME", + String.format("Argument at index '%s' is invalid for filter %s", i, getName()) + ); + } + namedArgs.put(argName, arg); + } + + //Parse args based on their declared types + Map parsedArgs = new HashMap<>(); + namedArgs.forEach((k, v) -> + parsedArgs.put(k, parseArg(interpreter, namedArguments.get(k), v)) + ); + + validateArgs(interpreter, parsedArgs); + + return filter(var, interpreter, parsedArgs); + } + + protected Object parseArg( + JinjavaInterpreter interpreter, + JinjavaParam jinjavaParamMetadata, + Object value + ) { + if ( + jinjavaParamMetadata.type() == null || + value == null || + Arrays.asList("object", "dict", "sequence").contains(jinjavaParamMetadata.type()) + ) { + return value; + } + String valueString = Objects.toString(value, null); + switch (jinjavaParamMetadata.type().toLowerCase()) { + case "boolean": + return value instanceof Boolean + ? (Boolean) value + : BooleanUtils.toBooleanObject(valueString); + case "int": + return value instanceof Integer + ? (Integer) value + : NumberUtils.toInt(valueString); + case "long": + return value instanceof Long ? (Long) value : NumberUtils.toLong(valueString); + case "float": + return value instanceof Float ? (Float) value : NumberUtils.toFloat(valueString); + case "double": + return value instanceof Double + ? (Double) value + : NumberUtils.toDouble(valueString); + case "number": + return value instanceof Number ? (Number) value : new BigDecimal(valueString); + case "string": + return valueString; + default: + throw new InvalidInputException( + interpreter, + "INVALID_ARG_NAME", + String.format( + "Argument named '%s' with value '%s' cannot be parsed for filter %s", + jinjavaParamMetadata.value(), + value, + getName() + ) + ); + } + } + + public void validateArgs( + JinjavaInterpreter interpreter, + Map parsedArgs + ) { + for (JinjavaParam jinjavaParam : namedArguments.values()) { + if (jinjavaParam.required() && !parsedArgs.containsKey(jinjavaParam.value())) { + throw new InvalidInputException( + interpreter, + "MISSING_REQUIRED_ARG", + String.format( + "Argument named '%s' is required but missing for filter %s", + jinjavaParam.value(), + getName() + ) + ); + } + } + } + + public int getNamedArgumentPosition(String argName) { + return Optional + .ofNullable(namedArguments) + .map(Map::keySet) + .map(ArrayList::new) + .flatMap(argNames -> Optional.of(argNames.indexOf(argName))) + .orElse(-1); + } + + public String getIndexedArgumentName(int position) { + return Optional + .ofNullable(namedArguments) + .map(Map::keySet) + .map(ArrayList::new) + .flatMap(argNames -> + Optional.ofNullable(argNames.size() > position ? argNames.get(position) : null) + ) + .orElse(null); + } + + public Map initNamedArguments() { + JinjavaDoc jinjavaDoc = this.getClass().getAnnotation(JinjavaDoc.class); + + if (jinjavaDoc != null && !Strings.isNullOrEmpty(jinjavaDoc.aliasOf())) { + // aliased functions extend the base function. We need to get the annotations on the base + jinjavaDoc = this.getClass().getSuperclass().getAnnotation(JinjavaDoc.class); + } + + if (jinjavaDoc != null) { + ImmutableMap.Builder namedArgsBuilder = + ImmutableMap.builder(); + + Arrays + .stream(jinjavaDoc.params()) + .forEachOrdered(jinjavaParam -> + namedArgsBuilder.put(jinjavaParam.value(), jinjavaParam) + ); + + return namedArgsBuilder.build(); + } else { + throw new UnsupportedOperationException( + String.format( + "%s: @JinjavaDoc must be configured for filter %s to function", + getClass(), + getName() + ) + ); + } + } + + public Map initDefaultValues() { + return namedArguments + .entrySet() + .stream() + .filter(e -> StringUtils.isNotEmpty(e.getValue().defaultValue())) + .collect( + ImmutableMap.toImmutableMap(Map.Entry::getKey, e -> e.getValue().defaultValue()) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AbstractSetFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AbstractSetFilter.java new file mode 100644 index 000000000..664f82760 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AbstractSetFilter.java @@ -0,0 +1,144 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.fn.TypeFunction; +import com.hubspot.jinjava.util.ForLoop; +import com.hubspot.jinjava.util.ObjectIterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public abstract class AbstractSetFilter implements AdvancedFilter { + + protected Object parseArgs(JinjavaInterpreter interpreter, Object[] args) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (a list to perform set function)" + ); + } + + return args[0]; + } + + protected Set objectToSet(Object var) { + Set result = new LinkedHashSet<>(); + ForLoop loop = ObjectIterator.getLoop(var); + while (loop.hasNext()) { + result.add(loop.next()); + } + return result; + } + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + Set varSet = objectToSet(var); + Set argSet = objectToSet(parseArgs(interpreter, args)); + + if (!varSet.isEmpty() && !argSet.isEmpty()) { + Object oneVar = varSet.iterator().next(); + Object oneArg = argSet.iterator().next(); + + boolean featureActive = interpreter + .getConfig() + .getFeatures() + .isActive( + BuiltInFeatures.INTEGER_SET_TO_LONG_CONVERSION, + interpreter.getContext() + ); + if (featureActive) { + if (oneVar instanceof Integer && oneArg instanceof Long) { + varSet = convertIntegersToLongs(varSet); + } else if (oneArg instanceof Integer && oneVar instanceof Long) { + argSet = convertIntegersToLongs(argSet); + } + } + + attachMismatchedTypesWarning(interpreter, varSet, argSet, oneVar, oneArg); + } + + return filter(varSet, argSet); + } + + public abstract Object filter(Set varSet, Set argSet); + + protected void attachMismatchedTypesWarning( + JinjavaInterpreter interpreter, + Set varSet, + Set argSet + ) { + if (varSet.isEmpty() || argSet.isEmpty()) { + return; + } + attachMismatchedTypesWarning( + interpreter, + varSet, + argSet, + varSet.iterator().next(), + argSet.iterator().next() + ); + } + + private void attachMismatchedTypesWarning( + JinjavaInterpreter interpreter, + Set varSet, + Set argSet, + Object oneVarObj, + Object oneArgObj + ) { + if (getTypeOfSetElements(varSet).equals(getTypeOfSetElements(argSet))) { + return; + } + if (potentiallyConvertibleNumbers(oneVarObj, oneArgObj)) { + return; + } + interpreter.addError( + new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.OTHER, + TemplateError.ErrorItem.FILTER, + String.format( + "Mismatched Types: input set has elements of type '%s' but arg set has elements of type '%s'. Use |map filter to convert sets to the same type for filter to work correctly.", + getTypeOfSetElements(varSet), + getTypeOfSetElements(argSet) + ), + "list", + interpreter.getLineNumber(), + interpreter.getPosition(), + null + ) + ); + } + + private boolean potentiallyConvertibleNumbers(Object oneVarObj, Object oneArgObj) { + return ( + (oneArgObj instanceof Integer && oneVarObj instanceof Long) || + (oneVarObj instanceof Integer && oneArgObj instanceof Long) + ); + } + + private Set convertIntegersToLongs(Set set) { + Set result = new LinkedHashSet<>(); + for (Object element : set) { + if (element instanceof Integer integer) { + result.add(integer.longValue()); + } else { + result.add(element); + } + } + return result; + } + + private String getTypeOfSetElements(Set set) { + return TypeFunction.type(set.iterator().next()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AddFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AddFilter.java index 37dbee21c..9670f6946 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/AddFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AddFilter.java @@ -15,47 +15,86 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import java.math.BigDecimal; @JinjavaDoc( - value = "adds a number to the existing value", - params = { - @JinjavaParam(value = "number", type = "number", desc = "Number or numeric variable to add to"), - @JinjavaParam(value = "addend", type = "number", desc = "The number added to the base number") - }, - snippets = { - @JinjavaSnippet( - code = "{% set my_num = 40 %} \n" + - "{{ my_num|add(13) }}") - }) + value = "adds a number to the existing value", + input = @JinjavaParam( + value = "number", + type = "number", + desc = "Number or numeric variable to add to", + required = true + ), + params = { + @JinjavaParam( + value = "addend", + type = "number", + desc = "The number added to the base number", + required = true + ), + }, + snippets = { + @JinjavaSnippet(code = "{% set my_num = 40 %} \n" + "{{ my_num|add(13) }}"), + } +) public class AddFilter implements Filter { @Override - public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - if (arg.length != 1) { - throw new InterpretException("filter add expects 1 arg >>> " + arg.length); + public Object filter(Object object, JinjavaInterpreter interpreter, String... args) { + if (object == null) { + return null; + } + + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (number to add to base)" + ); } + BigDecimal base; try { - BigDecimal base = new BigDecimal(Objects.toString(object)); - BigDecimal addend = new BigDecimal(Objects.toString(arg[0])); + base = new BigDecimal(object.toString()); + } catch (NumberFormatException e) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + object.toString() + ); + } + + if (args[0] == null) { + return base; + } - return base.add(addend); - } catch (Exception e) { - throw new InterpretException("filter add error", e); + BigDecimal addend; + try { + addend = new BigDecimal(args[0]); + } catch (NumberFormatException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + args[0] + ); } + + return base.add(addend); } @Override public String getName() { return "add"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AdvancedFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AdvancedFilter.java new file mode 100644 index 000000000..fa365e7c9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AdvancedFilter.java @@ -0,0 +1,48 @@ +/********************************************************************** +Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.Importable; +import java.util.HashMap; +import java.util.Map; + +public interface AdvancedFilter extends Importable, Filter { + /** + * Filter the specified template variable within the context of a render process. {{ myvar|myfiltername(arg1,arg2) }} + * + * @param var + * the variable which this filter should operate on + * @param interpreter + * current interpreter context + * @param args + * any positional arguments passed to this filter invocation + * @param kwargs + * any named arguments passed to this filter invocation + * @return the filtered form of the given variable + */ + Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ); + + // Default implementation to maintain backward-compatibility with old Filters + default Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return filter(var, interpreter, args, new HashMap<>()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilter.java new file mode 100644 index 000000000..044df68f8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilter.java @@ -0,0 +1,44 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; +import com.hubspot.jinjava.objects.collections.SnakeCaseAccessibleMap; +import java.util.Map; + +@JinjavaDoc( + value = "Allow keys on the provided camelCase map to be accessed using snake_case", + input = @JinjavaParam( + value = "map", + type = "dict", + desc = "The dict to make keys accessible using snake_case", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ {'fooBar': 'baz'}|allow_snake_case }}") } +) +public class AllowSnakeCaseFilter implements Filter { + + public static final String NAME = "allow_snake_case"; + + @Override + public String getName() { + return NAME; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (!(var instanceof Map)) { + return var; + } + Map map = (Map) var; + if (map instanceof PyMap) { + map = ((PyMap) map).toMap(); + } + return new SnakeCaseAccessibleMap( + new SizeLimitingPyMap(map, interpreter.getConfig().getMaxMapSize()) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/AttrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/AttrFilter.java index 2e8fffa0a..fef32e1a3 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/AttrFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/AttrFilter.java @@ -3,20 +3,32 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; @JinjavaDoc( - value = "Renders the attribute of a dictionary", - params = { - @JinjavaParam(value = "obj", desc = "The dictionary containing the attribute"), - @JinjavaParam(value = "name", desc = "The dictionary attribute name to access") - }, - snippets = { - @JinjavaSnippet( - desc = "The filter example below is equivalent to rendering a variable that exists within a dictionary, such as content.absolute_url.", - code = "{{ content|attr('absolute_url') }}") - }) + value = "Renders the attribute of a dictionary", + input = @JinjavaParam( + value = "obj", + desc = "The dictionary containing the attribute", + required = true + ), + params = { + @JinjavaParam( + value = "name", + desc = "The dictionary attribute name to access", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + desc = "The filter example below is equivalent to rendering a variable that exists within a dictionary, such as content.absolute_url.", + code = "{{ content|attr('absolute_url') }}" + ), + } +) public class AttrFilter implements Filter { @Override @@ -26,11 +38,18 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - if (args.length == 0) { - throw new InterpretException(getName() + " requires an attr name to use", interpreter.getLineNumber()); + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (attribute name to use)" + ); + } + + if (args[0] == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, "name"); } return interpreter.resolveProperty(var, args[0]); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Base64DecodeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Base64DecodeFilter.java new file mode 100644 index 000000000..ca4dae9dc --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Base64DecodeFilter.java @@ -0,0 +1,60 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@JinjavaDoc( + value = "Decode a base 64 input into a string.", + input = @JinjavaParam( + value = "input", + type = "string", + desc = "The base 64 input to decode.", + required = true + ), + params = { + @JinjavaParam( + value = "encoding", + type = "string", + desc = "The string encoding charset to use.", + defaultValue = "UTF-8" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Decode a Base 64-encoded ASCII string into a UTF-8 string", + code = "{{ 'eydmb28nOiBbJ2JhciddfQ=='|b64decode }}" + ), + @JinjavaSnippet( + desc = "Decode a Base 64-encoded ASCII string into a UTF-16 Little Endian string", + code = "{{ 'Adg33A=='|b64decode(encoding='utf-16le') }}" + ), + } +) +public class Base64DecodeFilter implements Filter { + + public static final String NAME = "b64decode"; + + @Override + public String getName() { + return NAME; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (!(var instanceof String)) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.STRING, 0, var); + } + Charset charset = Base64EncodeFilter.checkCharset(interpreter, this, args); + return new String( + Base64.getDecoder().decode((var.toString()).getBytes(StandardCharsets.US_ASCII)), + charset + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Base64EncodeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Base64EncodeFilter.java new file mode 100644 index 000000000..9dbfeb3f3 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Base64EncodeFilter.java @@ -0,0 +1,99 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.base.Joiner; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.Importable; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import java.util.Base64; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.net.util.Charsets; + +@JinjavaDoc( + value = "Encode the string input into base 64.", + input = @JinjavaParam( + value = "input", + type = "object", + desc = "The string input to encode into base 64.", + required = true + ), + params = { + @JinjavaParam( + value = "encoding", + type = "string", + desc = "The string encoding charset to use.", + defaultValue = "UTF-8" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Encode a value with UTF-8 encoding into a Base 64 ASCII string", + code = "{{ 'abcd'|b64encode }}" + ), + @JinjavaSnippet( + desc = "Encode a value with UTF-16 Little Endian encoding into a Base 64 ASCII string", + code = "{{ '\uD801\uDC37'|b64encode(encoding='utf-16le') }}" + ), + } +) +public class Base64EncodeFilter implements Filter { + + public static final String NAME = "b64encode"; + public static final String AVAILABLE_CHARSETS = Joiner + .on(", ") + .join( + StandardCharsets.US_ASCII.name(), + StandardCharsets.ISO_8859_1.name(), + StandardCharsets.UTF_8.name(), + StandardCharsets.UTF_16BE.name(), + StandardCharsets.UTF_16LE.name(), + StandardCharsets.UTF_16.name() + ); + + static Charset checkCharset( + JinjavaInterpreter interpreter, + Importable filter, + String... args + ) { + Charset charset = StandardCharsets.UTF_8; + if (args.length > 0) { + try { + charset = Charsets.toCharset(StringUtils.upperCase(args[0])); + } catch (UnsupportedCharsetException e) { + throw new InvalidArgumentException( + interpreter, + filter, + InvalidReason.ENUM, + 1, + args[0], + AVAILABLE_CHARSETS + ); + } + } + return charset; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + Charset charset = Base64EncodeFilter.checkCharset(interpreter, this, args); + byte[] bytes; + if (var instanceof byte[]) { + bytes = (byte[]) var; + } else { + bytes = PyishObjectMapper.getAsUnquotedPyishString(var).getBytes(charset); + } + return Base64.getEncoder().encodeToString(bytes); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/BaseDateFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/BaseDateFilter.java new file mode 100644 index 000000000..1ff22d089 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/BaseDateFilter.java @@ -0,0 +1,79 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.primitives.Longs; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public abstract class BaseDateFilter implements AdvancedFilter { + + private static final Map unitMap = Arrays + .stream(ChronoUnit.values()) + .collect(Collectors.toMap(u -> u.toString().toLowerCase(), u -> u)); + + protected long parseDiffAmount(JinjavaInterpreter interpreter, Object... args) { + if (args.length < 2) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 number (diff amount) and 1 string (diff unit) argument" + ); + } + + Object firstArg = args[0]; + if (firstArg == null) { + firstArg = 0; + } + + Long diff = Longs.tryParse(firstArg.toString()); + if (diff == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + firstArg.toString() + ); + } + return diff; + } + + protected ChronoUnit parseChronoUnit(JinjavaInterpreter interpreter, Object... args) { + if (args.length < 2) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 number (diff amount) and 1 string (diff unit) argument" + ); + } + + Object unitString = args[1]; + if (unitString == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 1); + } + + return getTemporalUnit(interpreter, unitString.toString()); + } + + protected ChronoUnit getTemporalUnit( + JinjavaInterpreter interpreter, + String temporalUnit + ) { + String lowercase = temporalUnit.toLowerCase(); + if (!unitMap.containsKey(lowercase)) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.TEMPORAL_UNIT, + 1, + lowercase + ); + } + return unitMap.get(lowercase); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/BatchFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/BatchFilter.java index b2b1a7ce1..e58f7fc58 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/BatchFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/BatchFilter.java @@ -1,50 +1,59 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "A filter that groups up items within a sequence", - params = { - @JinjavaParam(value = "value", desc = "The sequence or dict that the filter is applied to"), - @JinjavaParam(value = "linecount", type = "number", desc = "Number of items to include in the batch", defaultValue = "0"), - @JinjavaParam(value = "fill_with", desc = "Value used to fill up missing items") - }, - snippets = { - @JinjavaSnippet( - code = "{% set items=[1, 2, 3, 4, 5] %}\n\n" + - "\n" + - "{% for row in items|batch(3, ' ') %}\n" + - " \n" + - " {%- for column in row %}\n" + - " \n" + - " {%- endfor %}\n" + - " \n" + - "{% endfor %}\n" + - "
{{ column }}
", - output = "\n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - " \n" + - "
123
45 
") - }) + value = "A filter that groups up items within a sequence", + input = @JinjavaParam( + value = "value", + desc = "The sequence or dict that the filter is applied to", + required = true + ), + params = { + @JinjavaParam( + value = "linecount", + type = "number", + desc = "Number of items to include in the batch", + defaultValue = "0" + ), + @JinjavaParam(value = "fill_with", desc = "Value used to fill up missing items"), + }, + snippets = { + @JinjavaSnippet( + code = "{% set items=[1, 2, 3, 4, 5] %}\n\n" + + "\n" + + "{% for row in items|batch(3, ' ') %}\n" + + " \n" + + " {%- for column in row %}\n" + + " \n" + + " {%- endfor %}\n" + + " \n" + + "{% endfor %}\n" + + "
{{ column }}
", + output = "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "
123
45 
" + ), + } +) public class BatchFilter implements Filter { @Override @@ -92,5 +101,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return result; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilter.java new file mode 100644 index 000000000..878fe2533 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilter.java @@ -0,0 +1,108 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureActivationStrategy; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.Map; + +/** + * {@link ChronoUnit} for valid time units + */ +@JinjavaDoc( + value = "Calculates the time between two datetime objects", + input = @JinjavaParam( + value = "begin", + desc = "Datetime object or timestamp at the beginning of the period", + required = true + ), + params = { + @JinjavaParam( + value = "end", + desc = "Datetime object or timestamp at the end of the period", + required = true + ), + @JinjavaParam(value = "unit", desc = "Which temporal unit to use", required = true), + }, + snippets = { @JinjavaSnippet(code = "{{ begin|between_times(end, 'hours') }}") } +) +public class BetweenTimesFilter extends BaseDateFilter { + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + if (args.length < 2) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 datetime (end date) and 1 string (diff unit) argument" + ); + } + + ZonedDateTime start = getZonedDateTime(var, "begin"); + ZonedDateTime end = getZonedDateTime(args[0], "end"); + + Object args1 = args[1]; + if (args1 == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 1); + } + + TemporalUnit temporalUnit = getTemporalUnit(interpreter, args[1].toString()); + return temporalUnit.between(start, end); + } + + private ZonedDateTime getZonedDateTime(Object var, String position) { + if (var instanceof ZonedDateTime) { + return (ZonedDateTime) var; + } else if (var instanceof PyishDate) { + return ((PyishDate) var).toDateTime(); + } else { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + + if (var == null) { + interpreter.addError( + TemplateError.fromMissingFilterArgException( + new InvalidArgumentException( + interpreter, + getName() + " filter called with null " + position, + getName() + ) + ) + ); + + FeatureActivationStrategy feat = interpreter + .getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.FIXED_DATE_TIME_FILTER_NULL_ARG); + + if (feat.isActive(interpreter.getContext())) { + var = ((DateTimeFeatureActivationStrategy) feat).getActivateAt(); + } + } + + return Functions.getDateTimeArg(var, ZoneOffset.UTC); + } + } + + @Override + public String getName() { + return "between_times"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/BoolFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/BoolFilter.java new file mode 100644 index 000000000..a31ffdaec --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/BoolFilter.java @@ -0,0 +1,56 @@ +/** + * 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.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.apache.commons.lang3.BooleanUtils; + +/** + * bool(value) Convert value to boolean. + */ +@JinjavaDoc( + value = "Convert value into a boolean.", + input = @JinjavaParam( + value = "value", + desc = "The value to convert to a boolean", + required = true + ), + snippets = { + @JinjavaSnippet( + desc = "This example converts a text string value to a boolean", + code = "{% if \"true\"|bool == true %}hello world{% endif %}" + ), + } +) +public class BoolFilter implements Filter { + + @Override + public String getName() { + return "bool"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return false; + } + + final String str = var.toString(); + + return str.equals("1") ? Boolean.TRUE : BooleanUtils.toBoolean(str); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/CapitalizeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/CapitalizeFilter.java index 846c81899..692aca07c 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/CapitalizeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/CapitalizeFilter.java @@ -1,22 +1,25 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Capitalize a value. The first character will be uppercase, all others lowercase.", - params = { - @JinjavaParam(value = "string", desc = "String to capitalize the first letter of") - }, - snippets = { - @JinjavaSnippet( - code = "{% set sentence = \"the first letter of a sentence should always be capitalized.\" %}\n" + - "{{ sentence|capitalize }}") - }) + value = "Capitalize a value. The first character will be uppercase, all others lowercase.", + input = @JinjavaParam( + value = "string", + desc = "String to capitalize the first letter of", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set sentence = \"the first letter of a sentence should always be capitalized.\" %}\n" + + "{{ sentence|capitalize }}" + ), + } +) public class CapitalizeFilter implements Filter { @Override @@ -26,11 +29,14 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return null; + } + if (var instanceof String) { String value = (String) var; - return StringUtils.capitalize(value); + return StringUtils.capitalize(value.toLowerCase()); } return var; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/CenterFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/CenterFilter.java index 405171922..4c1f90c98 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/CenterFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/CenterFilter.java @@ -1,29 +1,33 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Uses whitespace to center the value in a field of a given width.", - params = { - @JinjavaParam(value = "value", desc = "Value to center"), - @JinjavaParam(value = "width", type = "number", defaultValue = "80", desc = "Width of field to center value in") - }, - snippets = { - @JinjavaSnippet( - desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
-            code = "
\n" +
-                "    {% set var = \"string to center\" %}\n" +
-                "    {{ var|center(80) }}\n" +
-                "
") - }) + value = "Uses whitespace to center the value in a field of a given width.", + input = @JinjavaParam(value = "value", desc = "Value to center", required = true), + params = { + @JinjavaParam( + value = "width", + type = "number", + defaultValue = "80", + desc = "Width of field to center value in" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
+      code = "
\n" +
+      "    {% set var = \"string to center\" %}\n" +
+      "    {{ var|center(80) }}\n" +
+      "
" + ), + } +) public class CenterFilter implements Filter { @Override @@ -33,14 +37,15 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String str = Objects.toString(var, ""); + if (var == null) { + return null; + } int size = 80; if (args.length > 0) { size = NumberUtils.toInt(args[0], 80); } - return StringUtils.center(str, size); + return StringUtils.center(var.toString(), size); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilter.java new file mode 100644 index 000000000..f15315bd8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilter.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.jsoup.Jsoup; + +@JinjavaDoc( + value = "Closes open HTML tags in a string", + input = @JinjavaParam(value = "s", desc = "String to close", required = true), + snippets = { @JinjavaSnippet(code = "{{ \"

Hello, world\"|closehtml }}") } +) +public class CloseHtmlFilter implements Filter { + + @Override + public String getName() { + return "closehtml"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return Jsoup.parseBodyFragment(Objects.toString(var)).body().html(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/CountFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/CountFilter.java index d8550cc91..3a29987ef 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/CountFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/CountFilter.java @@ -9,5 +9,4 @@ public class CountFilter extends LengthFilter { public String getName() { return "count"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/CutFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/CutFilter.java index 5a96ce5af..d8f64843f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/CutFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/CutFilter.java @@ -1,49 +1,57 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.util.ObjectValue; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Removes a string from the value from another string", - params = { - @JinjavaParam(value = "value", desc = "The original string"), - @JinjavaParam(value = "to_remove", desc = "String to remove from the original string") - }, - snippets = { - @JinjavaSnippet( - code = "{% set my_string = \"Hello world.\" %}\n" + - "{{ my_string|cut(' world') }}") - }) + value = "Removes a string from the value from another string", + input = @JinjavaParam(value = "value", desc = "The original string", required = true), + params = { + @JinjavaParam( + value = "to_remove", + desc = "String to remove from the original string", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set my_string = \"Hello world.\" %}\n" + "{{ my_string|cut(' world') }}" + ), + } +) public class CutFilter implements Filter { @Override public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - if (arg.length != 1) { - throw new InterpretException("filter cut expects 1 arg >>> " + arg.length); + if (arg.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (string to remove from target)" + ); } String cutee = arg[0]; - String origin = ObjectValue.printable(object); + String origin = Objects.toString(object, ""); return StringUtils.replace(origin, cutee, ""); } @@ -51,5 +59,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "cut"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilter.java index 060aa6efd..e0459a1b4 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilter.java @@ -9,5 +9,4 @@ public class DAliasedDefaultFilter extends DefaultFilter { public String getName() { return "d"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilter.java index 53b959936..1098c5da4 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilter.java @@ -7,16 +7,43 @@ import com.hubspot.jinjava.lib.fn.Functions; import com.hubspot.jinjava.objects.date.StrftimeFormatter; +/** + * @deprecated Superseded by {@link com.hubspot.jinjava.lib.filter.time.FormatDatetimeFilter} + */ +@Deprecated @JinjavaDoc( - value = "Formats a date object", - params = { - @JinjavaParam(value = "value", defaultValue = "current time", desc = "The date variable or UNIX timestamp to format"), - @JinjavaParam(value = "format", defaultValue = StrftimeFormatter.DEFAULT_DATE_FORMAT, desc = "The format of the date determined by the directives added to this parameter") - }, - snippets = { - @JinjavaSnippet(code = "{% content.updated|datetimeformat('%B %e, %Y') %}"), - @JinjavaSnippet(code = "{% content.updated|datetimeformat('%a %A %w %d %e %b %B %m %y %Y %H %I %k %l %p %M %S %f %z %Z %j %U %W %c %x %X %%') %}") - }) + value = "Formats a date object", + input = @JinjavaParam( + value = "value", + desc = "The date variable or UNIX timestamp to format", + required = true + ), + params = { + @JinjavaParam( + value = "format", + defaultValue = StrftimeFormatter.DEFAULT_DATE_FORMAT, + desc = "The format of the date determined by the directives added to this parameter" + ), + @JinjavaParam( + value = "timezone", + defaultValue = "utc", + desc = "Time zone of output date" + ), + @JinjavaParam( + value = "locale", + type = "string", + defaultValue = "us", + desc = "The language code to use when formatting the datetime" + ), + }, + snippets = { + @JinjavaSnippet(code = "{% content.updated|datetimeformat('%B %e, %Y') %}"), + @JinjavaSnippet( + code = "{% content.updated|datetimeformat('%a %A %w %d %e %b %B %m %y %Y %H %I %k %l %p %M %S %f %z %Z %j %U %W %c %x %X %%') %}" + ), + }, + deprecated = true +) public class DateTimeFormatFilter implements Filter { @Override @@ -25,16 +52,7 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, - String... args) { - - if (args.length > 0) { - return Functions.dateTimeFormat(var, args[0]); - } - else { - return Functions.dateTimeFormat(var); - } - + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return Functions.dateTimeFormat(var, args); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DatetimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DatetimeFilter.java index 8e0d61305..8a1038f4a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DatetimeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DatetimeFilter.java @@ -18,7 +18,11 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.interpret.JinjavaInterpreter; -@JinjavaDoc(value = "", aliasOf = "datetimeformat") +/** + * @deprecated Superseded by {@link com.hubspot.jinjava.lib.filter.time.FormatDatetimeFilter} + */ +@Deprecated +@JinjavaDoc(value = "", aliasOf = "datetimeformat", deprecated = true) public class DatetimeFilter extends DateTimeFormatFilter { @Override @@ -30,5 +34,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "date"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DefaultFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DefaultFilter.java index 2b8392409..d93170d6a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DefaultFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DefaultFilter.java @@ -15,59 +15,85 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.BooleanUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.objects.PyWrapper; import com.hubspot.jinjava.util.ObjectTruthValue; +import java.util.Map; +import java.util.Objects; @JinjavaDoc( - value = "If the value is undefined it will return the passed default value, otherwise the value of the variable", - params = { - @JinjavaParam(value = "value", desc = "The variable or value to test"), - @JinjavaParam(value = "default_value", desc = "Value to print when variable is not defined"), - @JinjavaParam(value = "boolean", type = "boolean", defaultValue = "False", desc = "Set to True to use with variables which evaluate to false") - }, - snippets = { - @JinjavaSnippet( - desc = "This will output the value of my_variable if the variable was defined, otherwise 'my_variable is not defined'", - code = "{{ my_variable|default('my_variable is not defined') }}"), - @JinjavaSnippet( - desc = "If you want to use default with variables that evaluate to false you have to set the second parameter to true", - code = "{{ ''|default('the string was empty', true) }}") - }) -public class DefaultFilter implements Filter { + value = "If the value is undefined it will return the passed default value, otherwise the value of the variable", + input = @JinjavaParam( + value = "value", + desc = "The variable or value to test", + required = true + ), + params = { + @JinjavaParam( + value = DefaultFilter.DEFAULT_VALUE_PARAM, + type = "object", + desc = "Value to print when variable is not defined", + required = true + ), + @JinjavaParam( + value = DefaultFilter.TRUTHY_PARAM, + type = "boolean", + defaultValue = "False", + desc = "Set to True to use with variables which evaluate to false" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This will output the value of my_variable if the variable was defined, otherwise 'my_variable is not defined'", + code = "{{ my_variable|default('my_variable is not defined') }}" + ), + @JinjavaSnippet( + desc = "If you want to use default with variables that evaluate to false you have to set the second parameter to true", + code = "{{ ''|default('the string was empty', true) }}" + ), + } +) +public class DefaultFilter extends AbstractFilter implements AdvancedFilter { - @Override - public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - boolean truthy = false; + public static final String DEFAULT_VALUE_PARAM = "default_value"; + public static final String TRUTHY_PARAM = "truthy"; - if (arg.length == 0) { - throw new InterpretException("default filter requires 1 or 2 args"); + @Override + public Object filter( + Object object, + JinjavaInterpreter interpreter, + Map parsedArgs + ) { + if (parsedArgs.size() < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires either 1 (default value to use) or 2 (default value to use, default with variables that evaluate to false) arguments" + ); } - if (arg.length == 2) { - truthy = BooleanUtils.toBoolean(arg[1]); - } + boolean truthy = Boolean.TRUE.equals(parsedArgs.get(TRUTHY_PARAM)); + Object defaultValue = parsedArgs.get(DEFAULT_VALUE_PARAM); if (truthy) { if (ObjectTruthValue.evaluate(object)) { return object; } - } - else if (object != null) { + } else if (object != null) { return object; } - return arg[0]; + return (defaultValue instanceof PyWrapper) || (defaultValue == null) + ? defaultValue + : Objects.toString(defaultValue); } @Override public String getName() { return "default"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DictSortFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DictSortFilter.java index 70fbb4bdd..3f3f3736f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DictSortFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DictSortFilter.java @@ -1,34 +1,43 @@ package com.hubspot.jinjava.lib.filter; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.Serializable; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Map.Entry; - import org.apache.commons.lang3.BooleanUtils; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaParam; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @JinjavaDoc( - value = "Sort a dict and yield (key, value) pairs.", - params = { - @JinjavaParam(value = "value", desc = "Dict to sort"), - @JinjavaParam(value = "case_sensitive", type = "boolean", defaultValue = "False", desc = "Determines whether or not the sorting is case sensitive"), - @JinjavaParam(value = "by", type = "enum key|value", defaultValue = "key", desc = "Sort by dict key or value") - }, - snippets = { - @JinjavaSnippet( - desc = "Sort the dict by value, case insensitive", - code = "{% for item in contact|dictsort(false, 'value') %}\n" + - " {{item}}\n" + - "{% endfor %}") - }) + value = "Sort a dict and yield (key, value) pairs.", + input = @JinjavaParam(value = "value", desc = "Dict to sort", required = true), + params = { + @JinjavaParam( + value = "case_sensitive", + type = "boolean", + defaultValue = "False", + desc = "Determines whether or not the sorting is case sensitive" + ), + @JinjavaParam( + value = "by", + type = "enum key|value", + defaultValue = "key", + desc = "Sort by dict key or value" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Sort the dict by value, case insensitive", + code = "{% for item in contact|dictsort(false, 'value') %}\n" + + " {{item}}\n" + + "{% endfor %}" + ), + } +) public class DictSortFilter implements Filter { @Override @@ -49,25 +58,27 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) boolean sortByKey = true; if (args.length > 1) { - sortByKey = "value".equalsIgnoreCase(args[1]); + sortByKey = !"value".equalsIgnoreCase(args[1]); } @SuppressWarnings("unchecked") Map dict = (Map) var; List> sorted = Lists.newArrayList(dict.entrySet()); - Collections.sort(sorted, new MapEntryComparator(caseSensitive, sortByKey)); + sorted.sort(new MapEntryComparator(caseSensitive, sortByKey)); return sorted; } - private static class MapEntryComparator implements Comparator>, Serializable { + private static class MapEntryComparator + implements Comparator>, Serializable { + private static final long serialVersionUID = 1L; private final boolean caseSensitive; private final boolean sortByKey; - public MapEntryComparator(boolean caseSensitive, boolean sortByKey) { + MapEntryComparator(boolean caseSensitive, boolean sortByKey) { this.caseSensitive = caseSensitive; this.sortByKey = sortByKey; } @@ -77,19 +88,22 @@ public MapEntryComparator(boolean caseSensitive, boolean sortByKey) { public int compare(Entry o1, Entry o2) { Object sVal1 = sortByKey ? o1.getKey() : o1.getValue(); Object sVal2 = sortByKey ? o2.getKey() : o2.getValue(); + if (sVal1 == null || sVal2 == null) { + return 0; + } int result = 0; if (!caseSensitive && sVal1 instanceof String && sVal2 instanceof String) { result = ((String) sVal1).compareToIgnoreCase((String) sVal2); - } - else if (Comparable.class.isAssignableFrom(sVal1.getClass()) && Comparable.class.isAssignableFrom(sVal2.getClass())) { + } else if ( + Comparable.class.isAssignableFrom(sVal1.getClass()) && + Comparable.class.isAssignableFrom(sVal2.getClass()) + ) { result = ((Comparable) sVal1).compareTo(sVal2); } return result; } - } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DifferenceFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DifferenceFilter.java new file mode 100644 index 000000000..2cc71f83d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DifferenceFilter.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.collect.Sets; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import java.util.ArrayList; +import java.util.Set; + +@JinjavaDoc( + value = "Returns a list containing elements present in the first list but not the second list", + input = @JinjavaParam( + value = "value", + type = "sequence", + desc = "The first list", + required = true + ), + params = { + @JinjavaParam( + value = "list", + type = "sequence", + desc = "The second list", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3]|difference([2, 3, 4]) }}") } +) +public class DifferenceFilter extends AbstractSetFilter { + + @Override + public Object filter(Set varSet, Set argSet) { + return new ArrayList<>(Sets.difference(varSet, argSet)); + } + + @Override + public String getName() { + return "difference"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DivideFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DivideFilter.java index 6110676bb..b950a0c76 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DivideFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DivideFilter.java @@ -15,77 +15,85 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.math.BigInteger; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.NumberOperations; +import java.math.BigDecimal; +import java.util.Map; @JinjavaDoc( - value = "Divides the current value by a divisor", - params = { - @JinjavaParam(value = "value", type = "number", desc = "The numerator to be divided"), - @JinjavaParam(value = "divisor", type = "number", desc = "The divisor to divide the value") - }, - snippets = { - @JinjavaSnippet( - code = "{% set numerator = 106 %}\n" + - "{% numerator|divide(2) %}") - }) -public class DivideFilter implements Filter { + value = "Divides the current value by a divisor", + input = @JinjavaParam( + value = "value", + type = "number", + desc = "The numerator to be divided", + required = true + ), + params = { + @JinjavaParam( + value = "divisor", + type = "number", + desc = "The divisor to divide the value", + required = true + ), + }, + snippets = { + @JinjavaSnippet(code = "{% set numerator = 106 %}\n" + "{% numerator|divide(2) %}"), + } +) +public class DivideFilter implements AdvancedFilter { + + private static final TruthyTypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); @Override - public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - if (arg.length != 1) { - throw new InterpretException("filter multiply expects 1 arg >>> " + arg.length); + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 number (divisor) argument" + ); } - String toMul = arg[0]; + Object toMul = args[0]; Number num; if (toMul != null) { - num = new BigDecimal(toMul); - } else { - return object; - } - if (object instanceof Integer) { - return (Integer) object / num.intValue(); - } - if (object instanceof Float) { - return (Float) object / num.floatValue(); - } - if (object instanceof Long) { - return (Long) object / num.longValue(); - } - if (object instanceof Short) { - return (Short) object / num.shortValue(); - } - if (object instanceof Double) { - return (Double) object / num.doubleValue(); - } - if (object instanceof BigDecimal) { - return ((BigDecimal) object).divide(BigDecimal.valueOf(num.doubleValue())); - } - if (object instanceof BigInteger) { - return ((BigInteger) object).divide(BigInteger.valueOf(num.longValue())); - } - if (object instanceof Byte) { - return (Byte) object / num.byteValue(); - } - if (object instanceof String) { - try { - return Double.valueOf((String) object) / num.doubleValue(); - } catch (Exception e) { - throw new InterpretException(object + " can't be dealed with multiply filter", e); + if ( + interpreter.getConfig().getEnablePreciseDivideFilter() && toMul instanceof Number + ) { + num = (Number) toMul; + } else { + try { + num = new BigDecimal(toMul.toString()); + } catch (NumberFormatException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + toMul + ); + } } + } else { + return var; } - return object; + + return NumberOperations.div(TYPE_CONVERTER, var, num); } @Override public String getName() { return "divide"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/DivisibleFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/DivisibleFilter.java index 5a3fa628c..c7471cc4c 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/DivisibleFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/DivisibleFilter.java @@ -18,23 +18,35 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; @JinjavaDoc( - value = "Evaluates to true if the value is divisible by the given number", - params = { - @JinjavaParam(value = "value", type = "number", desc = "The value to be divided"), - @JinjavaParam(value = "divisor", type = "number", desc = "The divisor to check if the value is divisible by") - }, - snippets = { - @JinjavaSnippet( - desc = "This example is an alternative to using the is divisibleby expression test", - code = "{% set num = 10 %}\n" + - "{% if num|divisible(2) %}\n" + - " The number is divisble by 2\n" + - "{% endif %}") - }) + value = "Evaluates to true if the value is divisible by the given number", + input = @JinjavaParam( + value = "value", + type = "number", + desc = "The value to be divided", + required = true + ), + params = { + @JinjavaParam( + value = "divisor", + type = "number", + desc = "The divisor to check if the value is divisible by", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example is an alternative to using the is divisibleby expression test", + code = "{% set num = 10 %}\n" + + "{% if num|divisible(2) %}\n" + + " The number is divisble by 2\n" + + "{% endif %}" + ), + } +) public class DivisibleFilter implements Filter { @Override @@ -43,8 +55,12 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar return false; } if (object instanceof Number) { - if (arg.length != 1) { - throw new InterpretException("filter divisible expects 1 arg >>> " + arg.length); + if (arg.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (number to divide by)" + ); } long factor = Long.parseLong(arg[0]); long value = ((Number) object).longValue(); @@ -59,5 +75,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "divisible"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/EAliasedEscapeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/EAliasedEscapeFilter.java index ce3abb01d..ba199eac3 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/EAliasedEscapeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/EAliasedEscapeFilter.java @@ -9,5 +9,4 @@ public class EAliasedEscapeFilter extends EscapeFilter { public String getName() { return "e"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/EscapeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeFilter.java index 58a296819..b48891a0b 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/EscapeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeFilter.java @@ -1,40 +1,39 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.filter; -import com.hubspot.jinjava.util.ObjectValue; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Converts the characters &, <, >, ‘, and ” in string s to HTML-safe sequences. " - + "Use this filter if you need to display text that might contain such characters in HTML. " - + "Marks return value as markup string.", - params = { - @JinjavaParam(value = "s", desc = "String to escape") - }, - snippets = { - @JinjavaSnippet( - code = "{% set escape_string = \"
This markup is printed as text
\" %}\n" + - "{{ escape_string|escape }}") - }) + value = "Converts the characters &, <, >, ‘, and ” in string s to HTML-safe sequences. " + + "Use this filter if you need to display text that might contain such characters in HTML. " + + "Marks return value as markup string.", + input = @JinjavaParam(value = "s", desc = "String to escape", required = true), + snippets = { + @JinjavaSnippet( + code = "{% set escape_string = \"
This markup is printed as text
\" %}\n" + + "{{ escape_string|escape }}" + ), + } +) public class EscapeFilter implements Filter { private static final String SAMP = "&"; @@ -46,25 +45,27 @@ public class EscapeFilter implements Filter { private static final String BSQ = "'"; private static final String BDQ = """; - private static final String[] TO_REPLACE = new String[] { - SAMP, SGT, SLT, "'", "\"" - }; - private static final String[] REPLACE_WITH = new String[] { - BAMP, BGT, BLT, BSQ, BDQ - }; + private static final String[] TO_REPLACE = new String[] { SAMP, SGT, SLT, "'", "\"" }; + private static final String[] REPLACE_WITH = new String[] { BAMP, BGT, BLT, BSQ, BDQ }; public static String escapeHtmlEntities(String input) { + for (int idx = 0; idx < TO_REPLACE.length; ++idx) { + input = input.replace(TO_REPLACE[idx], REPLACE_WITH[idx]); + } + return input; + } + + public static String oldEscapeHtmlEntities(String input) { return StringUtils.replaceEach(input, TO_REPLACE, REPLACE_WITH); } @Override public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - return escapeHtmlEntities(ObjectValue.printable(object)); + return escapeHtmlEntities(Objects.toString(object, "")); } @Override public String getName() { return "escape"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilter.java new file mode 100644 index 000000000..57823de6d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilter.java @@ -0,0 +1,79 @@ +/********************************************************************** + * Copyright (c) 2017 HubSpot 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.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; + +@JinjavaDoc( + value = "Converts the characters { and } in string s to Jinjava-safe sequences. " + + "Use this filter if you need to display text that might contain such characters in Jinjava. " + + "Marks return value as markup string.", + input = @JinjavaParam(value = "s", desc = "String to escape", required = true), + params = { + @JinjavaParam( + value = "all_braces", + type = "boolean", + desc = "Whether to only escape all curly braces or just when there are default expression, tag, or comment marks", + defaultValue = "true" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set escape_string = \"{{This markup is printed as text}}\" %}\n" + + "{{ escape_string|escape_jinjava }}" + ), + } +) +public class EscapeJinjavaFilter implements Filter { + + private static final String SLBRACE = "{"; + private static final String BLBRACE = "{"; + private static final String SRBRACE = "}"; + private static final String BRBRACE = "}"; + + private static final String[] TO_REPLACE = new String[] { SLBRACE, SRBRACE }; + private static final String[] REPLACE_WITH = new String[] { BLBRACE, BRBRACE }; + + public static String escapeJinjavaEntities(String input) { + return StringUtils.replaceEach(input, TO_REPLACE, REPLACE_WITH); + } + + public static String escapeFullJinjavaEntities(String input) { + return input + .replace("{{", BLBRACE + BLBRACE) + .replace("}}", BRBRACE + BRBRACE) + .replaceAll("\\{([{%#])", BLBRACE + "$1") + .replaceAll("([}%#])}", "$1" + BRBRACE); + } + + @Override + public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { + if (arg.length > 0 && "false".equals(arg[0])) { + return escapeFullJinjavaEntities(Objects.toString(object, "")); + } + return escapeJinjavaEntities(Objects.toString(object, "")); + } + + @Override + public String getName() { + return "escape_jinjava"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsFilter.java new file mode 100644 index 000000000..0b41d5421 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsFilter.java @@ -0,0 +1,114 @@ +/********************************************************************** + Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.util.Locale; +import java.util.Objects; + +@JinjavaDoc( + value = "Escapes strings so that they can be safely inserted into a JavaScript variable declaration", + input = @JinjavaParam(value = "s", desc = "String to escape", required = true), + snippets = { + @JinjavaSnippet( + code = "{% set escape_string = \"This string can safely be inserted into JavaScript\" %}\n" + + "{{ escape_string|escapejs }}" + ), + } +) +public class EscapeJsFilter implements Filter { + + @Override + public Object filter( + Object objectToFilter, + JinjavaInterpreter jinjavaInterpreter, + String... strings + ) { + String input = Objects.toString(objectToFilter, ""); + LengthLimitingStringBuilder builder = new LengthLimitingStringBuilder( + jinjavaInterpreter.getConfig().getMaxOutputSize() + ); + + for (int i = 0; i < input.length(); i++) { + char ch = input.charAt(i); + + if (ch > 0xfff) { + builder.append("\\u"); + builder.append(toHex(ch)); + } else if (ch > 0xff) { + builder.append("\\u0"); + builder.append(toHex(ch)); + } else if (ch > 0x7f) { + builder.append("\\u00"); + builder.append(toHex(ch)); + } else if (ch < 32) { + switch (ch) { + case '\b': + builder.append("\\b"); + break; + case '\f': + builder.append("\\f"); + break; + case '\n': + builder.append("\\n"); + break; + case '\t': + builder.append("\\t"); + break; + case '\r': + builder.append("\\r"); + break; + default: + if (ch > 0xf) { + builder.append("\\u00"); + builder.append(toHex(ch)); + } else { + builder.append("\\u000"); + builder.append(toHex(ch)); + } + break; + } + } else { + switch (ch) { + case '"': + builder.append("\\\""); + break; + case '\\': + builder.append("\\\\"); + break; + default: + builder.append(ch); + break; + } + } + } + + return builder.toString(); + } + + @Override + public String getName() { + return "escapejs"; + } + + private String toHex(char ch) { + return Integer.toHexString(ch).toUpperCase(Locale.ENGLISH); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilter.java new file mode 100644 index 000000000..78129498d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilter.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringEscapeUtils; + +@JinjavaDoc( + value = "Escapes strings so that they can be used as JSON values", + input = @JinjavaParam(value = "s", desc = "String to escape", required = true), + snippets = { @JinjavaSnippet(code = "{{String that contains JavaScript|escapejson}}") } +) +public class EscapeJsonFilter implements Filter { + + @Override + public String getName() { + return "escapejson"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return StringEscapeUtils.escapeJson(Objects.toString(var)); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilter.java index 1cfe50cc6..0653c0891 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilter.java @@ -1,26 +1,32 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Format the value like a ‘human-readable’ file size (i.e. 13 kB, 4.1 MB, 102 Bytes, etc).", - params = { - @JinjavaParam(value = "value", desc = "The value to convert to filesize format"), - @JinjavaParam(value = "binary", type = "boolean", defaultValue = "False", desc = "Use binary prefixes (Mebi, Gibi)") - }, - snippets = { - @JinjavaSnippet( - code = "{% set bytes = 100000 %}\n" + - "{{ bytes|filesizeformat }}") - }) + value = "Format the value like a ‘human-readable’ file size (i.e. 13 kB, 4.1 MB, 102 Bytes, etc).", + input = @JinjavaParam( + value = "value", + desc = "The value to convert to filesize format", + required = true + ), + params = { + @JinjavaParam( + value = "binary", + type = "boolean", + defaultValue = "False", + desc = "Use binary prefixes (Mebi, Gibi)" + ), + }, + snippets = { + @JinjavaSnippet(code = "{% set bytes = 100000 %}\n" + "{{ bytes|filesizeformat }}"), + } +) public class FileSizeFormatFilter implements Filter { @Override @@ -47,21 +53,39 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) } String[] sizes = binary ? BINARY_SIZES : DECIMAL_SIZES; - int unit = 1; + long unit = 1; String prefix = ""; for (int i = 0; i < sizes.length; i++) { - unit = (int) Math.pow(base, i + 2); + unit = (long) Math.pow(base, i + 2); prefix = sizes[i]; if (bytes < unit) { - return String.format("%.1f %s", (base * bytes / unit), prefix); + return String.format("%.1f %s", (double) ((base * bytes) / unit), prefix); } } - return String.format("%.1f %s", (base * bytes / unit), prefix); + return String.format("%.1f %s", (double) ((base * bytes) / unit), prefix); } - private static final String[] BINARY_SIZES = { "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB" }; - private static final String[] DECIMAL_SIZES = { "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; + private static final String[] BINARY_SIZES = { + "KiB", + "MiB", + "GiB", + "TiB", + "PiB", + "EiB", + "ZiB", + "YiB", + }; + private static final String[] DECIMAL_SIZES = { + "KB", + "MB", + "GB", + "TB", + "PB", + "EB", + "ZB", + "YB", + }; } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Filter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Filter.java index bd9e096dc..fabffdd54 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/Filter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Filter.java @@ -1,36 +1,87 @@ /********************************************************************** -Copyright (c) 2014 HubSpot Inc. + Copyright (c) 2014 HubSpot 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 + 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 + 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. + 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.hubspot.jinjava.lib.filter; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.lib.Importable; +import com.hubspot.jinjava.objects.SafeString; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang3.ArrayUtils; public interface Filter extends Importable { - /** * Filter the specified template variable within the context of a render process. {{ myvar|myfiltername(arg1,arg2) }} * - * @param var - * the variable which this filter should operate on - * @param interpreter - * current interpreter context - * @param args - * any arguments passed to this filter invocation + * @param var the variable which this filter should operate on + * @param interpreter current interpreter context + * @param args any arguments passed to this filter invocation * @return the filtered form of the given variable */ Object filter(Object var, JinjavaInterpreter interpreter, String... args); + /* + * The JinJava parser calls filters giving to them two lists of parameters: + * - Positional arguments as Object[] + * - Named arguments as Map + * + * This default method transforms that call to a simple filter that only receives String positional arguments to + * maintain backward-compatibility with old filters that don't support named arguments. + */ + default Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + // We append the named arguments at the end of the positional ones + Object[] allArgs = ArrayUtils.addAll(args, kwargs.values().toArray()); + + List stringArgs = new ArrayList<>(); + + for (Object arg : allArgs) { + stringArgs.add(arg == null ? null : Objects.toString(arg)); + } + + String[] filterArgs = new String[stringArgs.size()]; + for (int i = 0; i < stringArgs.size(); i++) { + filterArgs[i] = stringArgs.get(i); + } + + if (var instanceof SafeString) { + return filter((SafeString) var, interpreter, filterArgs); + } + + return filter(var, interpreter, filterArgs); + } + + default boolean preserveSafeString() { + return true; + } + + default Object filter(SafeString var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return filter((Object) null, interpreter, args); + } + Object filteredValue = filter(var.toString(), interpreter, args); + if (preserveSafeString() && filteredValue instanceof String) { + return new SafeString(filteredValue.toString()); + } + return filteredValue; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java index 1729f9a53..f5dfc30e3 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java @@ -16,83 +16,116 @@ package com.hubspot.jinjava.lib.filter; import com.hubspot.jinjava.lib.SimpleLibrary; +import com.hubspot.jinjava.lib.filter.time.FormatDateFilter; +import com.hubspot.jinjava.lib.filter.time.FormatDatetimeFilter; +import com.hubspot.jinjava.lib.filter.time.FormatTimeFilter; +import java.util.Set; public class FilterLibrary extends SimpleLibrary { - public FilterLibrary(boolean registerDefaults) { - super(registerDefaults); + public FilterLibrary(boolean registerDefaults, Set disabled) { + super(registerDefaults, disabled); } @Override protected void registerDefaults() { registerClasses( - AttrFilter.class, - PrettyPrintFilter.class, - - DefaultFilter.class, - DAliasedDefaultFilter.class, - FileSizeFormatFilter.class, - UrlizeFilter.class, - - BatchFilter.class, - CountFilter.class, - DictSortFilter.class, - FirstFilter.class, - GroupByFilter.class, - JoinFilter.class, - LastFilter.class, - LengthFilter.class, - ListFilter.class, - MapFilter.class, - RejectAttrFilter.class, - RejectFilter.class, - SelectFilter.class, - SelectAttrFilter.class, - SliceFilter.class, - ShuffleFilter.class, - SortFilter.class, - SplitFilter.class, - UniqueFilter.class, - - DatetimeFilter.class, - DateTimeFormatFilter.class, - - AbsFilter.class, - AddFilter.class, - CutFilter.class, - DivideFilter.class, - DivisibleFilter.class, - FloatFilter.class, - IntFilter.class, - Md5Filter.class, - MultiplyFilter.class, - RandomFilter.class, - ReverseFilter.class, - RoundFilter.class, - SumFilter.class, - - EscapeFilter.class, - EAliasedEscapeFilter.class, - ForceEscapeFilter.class, - StripTagsFilter.class, - UrlEncodeFilter.class, - XmlAttrFilter.class, - - CapitalizeFilter.class, - CenterFilter.class, - FormatFilter.class, - IndentFilter.class, - LowerFilter.class, - TruncateFilter.class, - TruncateHtmlFilter.class, - UpperFilter.class, - ReplaceFilter.class, - StringFilter.class, - SafeFilter.class, - TitleFilter.class, - TrimFilter.class, - WordCountFilter.class, - WordWrapFilter.class); + AbsFilter.class, + AddFilter.class, + AllowSnakeCaseFilter.class, + AttrFilter.class, + Base64DecodeFilter.class, + Base64EncodeFilter.class, + BatchFilter.class, + BetweenTimesFilter.class, + BoolFilter.class, + CapitalizeFilter.class, + CenterFilter.class, + CountFilter.class, + CutFilter.class, + DAliasedDefaultFilter.class, + DateTimeFormatFilter.class, + DatetimeFilter.class, + DefaultFilter.class, + DictSortFilter.class, + DifferenceFilter.class, + DivideFilter.class, + DivisibleFilter.class, + EAliasedEscapeFilter.class, + EscapeFilter.class, + EscapeJinjavaFilter.class, + EscapeJsFilter.class, + EscapeJsonFilter.class, + FileSizeFormatFilter.class, + FirstFilter.class, + FloatFilter.class, + ForceEscapeFilter.class, + FormatFilter.class, + FormatDateFilter.class, + FormatDatetimeFilter.class, + FormatNumberFilter.class, + FormatTimeFilter.class, + FromJsonFilter.class, + FromYamlFilter.class, + GroupByFilter.class, + IndentFilter.class, + IntFilter.class, + IntersectFilter.class, + IpAddrFilter.class, + Ipv4Filter.class, + Ipv6Filter.class, + JoinFilter.class, + LastFilter.class, + LengthFilter.class, + ListFilter.class, + LogFilter.class, + LowerFilter.class, + MapFilter.class, + Md5Filter.class, + MinusTimeFilter.class, + MultiplyFilter.class, + PlusTimeFilter.class, + PrettyPrintFilter.class, + RandomFilter.class, + RegexReplaceFilter.class, + RejectFilter.class, + RejectAttrFilter.class, + RenderFilter.class, + ReplaceFilter.class, + ReverseFilter.class, + RootFilter.class, + RoundFilter.class, + SafeFilter.class, + SelectFilter.class, + SelectAttrFilter.class, + ShuffleFilter.class, + SliceFilter.class, + SortFilter.class, + SplitFilter.class, + StringFilter.class, + StringToDateFilter.class, + StringToTimeFilter.class, + StripTagsFilter.class, + SumFilter.class, + SymmetricDifferenceFilter.class, + TitleFilter.class, + ToJsonFilter.class, + ToYamlFilter.class, + TrimFilter.class, + TruncateFilter.class, + TruncateHtmlFilter.class, + UnescapeHtmlFilter.class, + UnionFilter.class, + UniqueFilter.class, + UnixTimestampFilter.class, + UpperFilter.class, + UrlDecodeFilter.class, + UrlEncodeFilter.class, + UrlizeFilter.class, + WordCountFilter.class, + WordWrapFilter.class, + XmlAttrFilter.class + ); } public Filter getFilter(String filterName) { @@ -102,5 +135,4 @@ public Filter getFilter(String filterName) { public void addFilter(Filter filter) { register(filter); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FirstFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FirstFilter.java index 30bfddffe..2f9ff5e1a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FirstFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FirstFilter.java @@ -8,15 +8,20 @@ import com.hubspot.jinjava.util.ObjectIterator; @JinjavaDoc( - value = "Return the first item of a sequence.", - params = { - @JinjavaParam(value = "seq", type = "sequence", desc = "Sequence to return first item from") - }, - snippets = { - @JinjavaSnippet( - code = "{% set my_sequence = ['Item 1', 'Item 2', 'Item 3'] %}\n" + - "{{ my_sequence|first }}") - }) + value = "Return the first item of a sequence.", + input = @JinjavaParam( + value = "seq", + type = "sequence", + desc = "Sequence to return first item from", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set my_sequence = ['Item 1', 'Item 2', 'Item 3'] %}\n" + + "{{ my_sequence|first }}" + ), + } +) public class FirstFilter implements Filter { @Override @@ -29,5 +34,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) ForLoop loop = ObjectIterator.getLoop(var); return loop.next(); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FloatFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FloatFilter.java index 866b19a7d..ffbca6231 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FloatFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FloatFilter.java @@ -1,24 +1,37 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.util.Locale; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Convert the value into a floating point number.", - params = { - @JinjavaParam(value = "value", desc = "Value to convert to a float"), - @JinjavaParam(value = "default", type = "float", defaultValue = "0.0", desc = "Value to return if conversion fails") - }, - snippets = { - @JinjavaSnippet( - desc = "This example converts a text field string value to a float", - code = "{% text \"my_text\" value='25', export_to_template_context=True %}\n" + - "{% widget_data.my_text.value|float + 28 %}") - }) + value = "Convert the value into a floating point number.", + input = @JinjavaParam( + value = "value", + desc = "Value to convert to a float", + required = true + ), + params = { + @JinjavaParam( + value = "default", + type = "float", + defaultValue = "0.0", + desc = "Value to return if conversion fails" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example converts a text field string value to a float", + code = "{% text \"my_text\" value='25', export_to_template_context=True %}\n" + + "{% widget_data.my_text.value|float + 28 %}" + ), + } +) public class FloatFilter implements Filter { @Override @@ -37,11 +50,26 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return defaultVal; } + if (Float.class.isAssignableFrom(var.getClass())) { + return var; + } if (Number.class.isAssignableFrom(var.getClass())) { return ((Number) var).floatValue(); } - return NumberUtils.toFloat(var.toString(), defaultVal); + String input = var.toString(); + Locale locale = interpreter.getConfig().getLocale(); + NumberFormat numberFormat = NumberFormat.getNumberInstance(locale); + ParsePosition pp = new ParsePosition(0); + float result; + try { + result = numberFormat.parse(input, pp).floatValue(); + } catch (Exception e) { + result = defaultVal; + } + if (pp.getErrorIndex() != -1 || pp.getIndex() != input.length()) { + result = defaultVal; + } + return result; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ForceEscapeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ForceEscapeFilter.java index 3b631bd51..c6acfea55 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/ForceEscapeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ForceEscapeFilter.java @@ -1,22 +1,22 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.StringEscapeUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringEscapeUtils; @JinjavaDoc( - value = "Enforce HTML escaping. This will probably double escape variables.", - params = @JinjavaParam(value = "value", desc = "Value to escape"), - snippets = { - @JinjavaSnippet( - code = "{% set escape_string = \"
This markup is printed as text
\" %}\n" + - "{{ escape_string|forceescape }}\n") - }) + value = "Enforce HTML escaping. This will probably double escape variables.", + input = @JinjavaParam(value = "value", desc = "Value to escape", required = true), + snippets = { + @JinjavaSnippet( + code = "{% set escape_string = \"
This markup is printed as text
\" %}\n" + + "{{ escape_string|forceescape }}\n" + ), + } +) public class ForceEscapeFilter implements Filter { @Override @@ -28,5 +28,4 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { return StringEscapeUtils.escapeHtml4(Objects.toString(var, "")); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FormatFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FormatFilter.java index e2c7721ab..a22fa6f22 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FormatFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FormatFilter.java @@ -1,34 +1,86 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.IllegalFormatConversionException; +import java.util.IllegalFormatException; +import java.util.Map; +import java.util.MissingFormatArgumentException; +import java.util.MissingFormatWidthException; +import java.util.Objects; @JinjavaDoc( - value = "Apply Python string formatting to an object.", - params = { - @JinjavaParam(value = "value", desc = "String value to reformat"), - @JinjavaParam(value = "args", type = "String...", desc = "Values to insert into string") - }, - snippets = { - @JinjavaSnippet( - desc = "%s can be replaced with other variables or values", - code = "{{ \"Hi %s %s\"|format(contact.firstname, contact.lastname) }} ") - }) -public class FormatFilter implements Filter { + value = "Apply Python string formatting to an object.", + input = @JinjavaParam( + value = "value", + desc = "String value to reformat", + required = true + ), + params = { + @JinjavaParam( + value = "args", + type = "String...", + desc = "Values to insert into string" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "%s can be replaced with other variables or values", + code = "{{ \"Hi %s %s\"|format(contact.firstname, contact.lastname) }} " + ), + } +) +public class FormatFilter implements AdvancedFilter { + + public static final String NAME = "format"; @Override public String getName() { - return "format"; + return NAME; } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - String fmt = Objects.toString(var, ""); - return String.format(fmt, (Object[]) args); + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + try { + return String.format(Objects.toString(var, ""), args); + } catch (IllegalFormatException e) { + if (e instanceof MissingFormatArgumentException) { + throw new InvalidArgumentException( + interpreter, + NAME, + "Missing format argument for '" + + ((MissingFormatArgumentException) e).getFormatSpecifier() + + "'" + ); + } else if (e instanceof IllegalFormatConversionException) { + throw new InvalidArgumentException( + interpreter, + NAME, + "'" + + args[0] + + "' is not a compatible type for conversion to format specifier '" + + ((IllegalFormatConversionException) e).getConversion() + + "'" + ); + } else if (e instanceof MissingFormatWidthException) { + throw new InvalidArgumentException( + interpreter, + NAME, + "'" + + ((MissingFormatWidthException) e).getFormatSpecifier() + + "' is missing a width" + ); + } + // could possibly handle other subclasses of IllegalFormatException here if they come up + throw new InvalidArgumentException(interpreter, NAME, e.getMessage()); + } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FormatNumberFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FormatNumberFilter.java new file mode 100644 index 000000000..7a721b56c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FormatNumberFilter.java @@ -0,0 +1,115 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +@JinjavaDoc( + value = "Formats a given number based on the locale passed in as a parameter.", + input = @JinjavaParam( + value = "value", + desc = "The number to be formatted based on locale", + required = true + ), + params = { + @JinjavaParam( + value = "locale", + desc = "Locale in which to format the number. The default is the page's locale." + ), + @JinjavaParam( + value = "max decimal precision", + type = "number", + desc = "A number input that determines the decimal precision of the formatted value. If the number of decimal digits from the input value is less than the decimal precision number, use the number of decimal digits from the input value. Otherwise, use the decimal precision number. The default is the number of decimal digits from the input value." + ), + }, + snippets = { + @JinjavaSnippet(code = "{{ number|format_number }}"), + @JinjavaSnippet(code = "{{ number|format_number(\"en-US\") }}"), + @JinjavaSnippet(code = "{{ number|format_number(\"en-US\", 3) }}"), + } +) +public class FormatNumberFilter implements Filter { + + private static final String FORMAT_NUMBER_FILTER_NAME = "format_number"; + + @Override + public String getName() { + return FORMAT_NUMBER_FILTER_NAME; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + Locale locale = args.length > 0 && !Strings.isNullOrEmpty(args[0]) + ? Locale.forLanguageTag(args[0]) + : interpreter.getConfig().getLocale(); + + BigDecimal number; + try { + number = parseInput(var); + } catch (Exception e) { + if (interpreter.getContext().isValidationMode()) { + return ""; + } + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.INVALID_INPUT, + ErrorItem.FILTER, + "Input value '" + var + "' could not be parsed.", + null, + interpreter.getLineNumber(), + e, + null, + ImmutableMap.of("value", Objects.toString(var)) + ) + ); + return var; + } + + Optional maxDecimalPrecision = args.length > 1 + ? Optional.of(Integer.parseInt(args[1])) + : Optional.empty(); + + return formatNumber(locale, number, maxDecimalPrecision); + } + + private BigDecimal parseInput(Object input) throws Exception { + DecimalFormat df = (DecimalFormat) NumberFormat.getInstance(); + df.setParseBigDecimal(true); + + return (BigDecimal) df.parseObject(Objects.toString(input)); + } + + private String formatNumber( + Locale locale, + BigDecimal number, + Optional maxDecimalPrecision + ) { + NumberFormat numberFormat = NumberFormat.getNumberInstance(locale); + int numDecimalPlacesInInput = Math.max(0, number.scale()); + + numberFormat.setMaximumFractionDigits( + Math.min( + numDecimalPlacesInInput, + maxDecimalPrecision.isPresent() + ? maxDecimalPrecision.get() + : numDecimalPlacesInInput + ) + ); + + return numberFormat.format(number); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FromJsonFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FromJsonFilter.java new file mode 100644 index 000000000..bacee747d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FromJsonFilter.java @@ -0,0 +1,45 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.io.IOException; + +@JinjavaDoc( + value = "Converts JSON string to Object", + input = @JinjavaParam( + value = "string", + desc = "JSON String to write to object", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{object|fromJson}}") } +) +public class FromJsonFilter implements Filter { + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return null; + } + + if (!(var instanceof String)) { + throw new InvalidInputException(interpreter, this, InvalidReason.STRING); + } + try { + return interpreter + .getConfig() + .getObjectMapper() + .readValue((String) var, Object.class); + } catch (IOException e) { + throw new InvalidInputException(interpreter, this, InvalidReason.JSON_READ); + } + } + + @Override + public String getName() { + return "fromjson"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FromYamlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/FromYamlFilter.java new file mode 100644 index 000000000..7f79e709e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FromYamlFilter.java @@ -0,0 +1,45 @@ +package com.hubspot.jinjava.lib.filter; + +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.io.IOException; + +@JinjavaDoc( + value = "Converts a YAML string to an object", + input = @JinjavaParam( + value = "string", + desc = "YAML String to convert to an object", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{object|fromYaml}}") } +) +public class FromYamlFilter implements Filter { + + private static final YAMLMapper OBJECT_MAPPER = new YAMLMapper(); + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return null; + } + + if (!(var instanceof String)) { + throw new InvalidInputException(interpreter, this, InvalidReason.STRING); + } + try { + return OBJECT_MAPPER.readValue((String) var, Object.class); + } catch (IOException e) { + throw new InvalidInputException(interpreter, this, InvalidReason.JSON_READ); + } + } + + @Override + public String getName() { + return "fromyaml"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/GroupByFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/GroupByFilter.java index f0860fdd5..d19c7620c 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/GroupByFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/GroupByFilter.java @@ -1,38 +1,49 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Lists; import com.google.common.collect.Multimap; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; @JinjavaDoc( - value = "Group a sequence of objects by a common attribute.", - params = { - @JinjavaParam(value = "value", desc = "The dict to iterate through and group by a common attribute"), - @JinjavaParam(value = "attribute", desc = "The common attribute to group by") - }, - snippets = { - @JinjavaSnippet( - desc = "If you have a list of dicts or objects that represent persons with gender, first_name and last_name attributes and you want to group all users by genders you can do something like this", - code = "
    \n" + - " {% for group in contents|groupby('blog_post_author') %}\n" + - "
  • {{ group.grouper }}
      \n" + - " {% for content in group.list %}\n" + - "
    • {{ content.name }}
    • \n" + - " {% endfor %}
  • \n" + - " {% endfor %}\n" + - "
") - }) + value = "Group a sequence of objects by a common attribute.", + input = @JinjavaParam( + value = "value", + desc = "The dict to iterate through and group by a common attribute", + required = true + ), + params = { + @JinjavaParam( + value = "attribute", + desc = "The common attribute to group by", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + desc = "If you have a list of dicts or objects that represent persons with gender, first_name and last_name attributes and you want to group all users by genders you can do something like this", + code = "
    \n" + + " {% for group in contents|groupby('blog_post_author') %}\n" + + "
  • {{ group.grouper }}
      \n" + + " {% for content in group.list %}\n" + + "
    • {{ content.name }}
    • \n" + + " {% endfor %}
  • \n" + + " {% endfor %}\n" + + "
" + ), + } +) public class GroupByFilter implements Filter { @Override @@ -42,37 +53,50 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - if (args.length == 0) { - throw new InterpretException(getName() + " requires an attr name to group on", interpreter.getLineNumber()); + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (attr name to group by)" + ); } String attr = args[0]; ForLoop loop = ObjectIterator.getLoop(var); - Multimap groupBuckets = ArrayListMultimap.create(); + Multimap groupBuckets = LinkedListMultimap.create(); + Map groupMapRaw = new HashMap<>(); while (loop.hasNext()) { Object val = loop.next(); - String grouper = Objects.toString(interpreter.resolveProperty(val, attr)); + Object resolvedProperty = interpreter.resolveProperty(val, attr); + String grouper = Objects.toString(resolvedProperty); groupBuckets.put(grouper, val); + + if (!groupMapRaw.containsKey(grouper)) { + groupMapRaw.put(grouper, resolvedProperty); + } } List groups = new ArrayList<>(); for (String grouper : groupBuckets.keySet()) { List list = Lists.newArrayList(groupBuckets.get(grouper)); - groups.add(new Group(grouper, list)); + groups.add(new Group(grouper, groupMapRaw.get(grouper), list)); } return groups; } public static class Group { + private final String grouper; + private final Object grouperObject; private final List list; - public Group(String grouper, List list) { + public Group(String grouper, Object grouperObject, List list) { this.grouper = grouper; + this.grouperObject = grouperObject; this.list = list; } @@ -80,9 +104,12 @@ public String getGrouper() { return grouper; } + public Object getGrouperObject() { + return grouperObject; + } + public List getList() { return list; } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/IndentFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/IndentFilter.java index b2de21644..d40745594 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/IndentFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/IndentFilter.java @@ -1,35 +1,48 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; - import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Uses whitespace to indent a string.", - params = { - @JinjavaParam(value = "s", desc = "The string to indent"), - @JinjavaParam(value = "width", type = "number", defaultValue = "4", desc = "Amount of whitespace to indent"), - @JinjavaParam(value = "indentfirst", type = "boolean", defaultValue = "False", desc = "If True, first line will be indented") - }, - snippets = { - @JinjavaSnippet(desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
-            code = "
\n" +
-                "    {% set var = \"string to indent\" %}\n" +
-                "    {{ var|indent(2, true) }}\n" +
-                "
") - }) -public class IndentFilter implements Filter { + value = "Uses whitespace to indent a string.", + input = @JinjavaParam(value = "string", desc = "The string to indent", required = true), + params = { + @JinjavaParam( + value = IndentFilter.WIDTH_PARAM, + type = "number", + defaultValue = "4", + desc = "Amount of whitespace to indent" + ), + @JinjavaParam( + value = IndentFilter.INDENT_FIRST_PARAM, + type = "boolean", + defaultValue = "False", + desc = "If True, first line will be indented" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
+      code = "
\n" +
+      "    {% set var = \"string to indent\" %}\n" +
+      "    {{ var|indent(2, true) }}\n" +
+      "
" + ), + } +) +public class IndentFilter extends AbstractFilter { + + public static final String INDENT_FIRST_PARAM = "indentfirst"; + public static final String WIDTH_PARAM = "width"; @Override public String getName() { @@ -37,16 +50,14 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - int width = 4; - if (args.length > 0) { - width = NumberUtils.toInt(args[0], 4); - } + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Map parsedArgs + ) { + int width = ((Number) parsedArgs.get(WIDTH_PARAM)).intValue(); - boolean indentFirst = false; - if (args.length > 1) { - indentFirst = BooleanUtils.toBoolean(args[1]); - } + boolean indentFirst = (boolean) parsedArgs.get(INDENT_FIRST_PARAM); List indentedLines = new ArrayList<>(); for (String line : NEWLINE_SPLITTER.split(Objects.toString(var, ""))) { @@ -59,5 +70,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) private static final Splitter NEWLINE_SPLITTER = Splitter.on('\n'); private static final Joiner NEWLINE_JOINER = Joiner.on('\n'); - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/IntFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/IntFilter.java index 1b5f7b7d6..59d2176e9 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/IntFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/IntFilter.java @@ -1,27 +1,40 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.util.Locale; +import org.apache.commons.lang3.math.NumberUtils; /** - * int(value, default=0) Convert the value into an integer. If the conversion doesn’t work it will return 0. You can override this default using the first parameter. + * int(value, default=0) Convert the value into an integer. If the conversion doesn't work it will return 0. You can override this default using the first parameter. */ @JinjavaDoc( - value = "Convert the value into an integer.", - params = { - @JinjavaParam(value = "value", desc = "The value to convert to an integer"), - @JinjavaParam(value = "default", type = "number", defaultValue = "0", desc = "Value to return if the conversion fails") - }, - snippets = { - @JinjavaSnippet( - desc = "This example converts a text field string value to a integer", - code = "{% text \"my_text\" value='25', export_to_template_context=True %}\n" + - "{% widget_data.my_text.value|int + 28 %}") - }) + value = "Convert the value into an integer.", + input = @JinjavaParam( + value = "value", + desc = "The value to convert to an integer", + required = true + ), + params = { + @JinjavaParam( + value = "default", + type = "number", + defaultValue = "0", + desc = "Value to return if the conversion fails" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example converts a text field string value to a integer", + code = "{% text \"my_text\" value='25', export_to_template_context=True %}\n" + + "{% widget_data.my_text.value|int + 28 %}" + ), + } +) public class IntFilter implements Filter { @Override @@ -30,23 +43,40 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, - String... args) { - - int defaultVal = 0; + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + Long defaultVal = 0L; if (args.length > 0) { - defaultVal = NumberUtils.toInt(args[0], 0); + defaultVal = NumberUtils.toLong(args[0], 0); } if (var == null) { - return defaultVal; + return convertResult(defaultVal); } if (Number.class.isAssignableFrom(var.getClass())) { - return ((Number) var).intValue(); + return convertResult(((Number) var).longValue()); } - return NumberUtils.toInt(var.toString(), defaultVal); + String input = var.toString().trim(); + Locale locale = interpreter.getConfig().getLocale(); + NumberFormat numberFormat = NumberFormat.getNumberInstance(locale); + ParsePosition pp = new ParsePosition(0); + Long result; + try { + result = numberFormat.parse(input, pp).longValue(); + } catch (Exception e) { + result = defaultVal; + } + if (pp.getErrorIndex() != -1 || pp.getIndex() != input.length()) { + result = defaultVal; + } + return convertResult(result); } + private Object convertResult(Long result) { + if (result < Integer.MIN_VALUE || result > Integer.MAX_VALUE) { + return result; + } + return result.intValue(); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/IntersectFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/IntersectFilter.java new file mode 100644 index 000000000..c08453b29 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/IntersectFilter.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.collect.Sets; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import java.util.ArrayList; +import java.util.Set; + +@JinjavaDoc( + value = "Returns a list containing elements present in both lists", + input = @JinjavaParam( + value = "value", + type = "sequence", + desc = "The first list", + required = true + ), + params = { + @JinjavaParam( + value = "list", + type = "sequence", + desc = "The second list", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3]|intersect([2, 3, 4]) }}") } +) +public class IntersectFilter extends AbstractSetFilter { + + @Override + public Object filter(Set varSet, Set argSet) { + return new ArrayList<>(Sets.intersection(varSet, argSet)); + } + + @Override + public String getName() { + return "intersect"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java new file mode 100644 index 000000000..9fa3bb257 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java @@ -0,0 +1,337 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import com.googlecode.ipv6.IPv6Network; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.ForLoop; +import com.hubspot.jinjava.util.ObjectIterator; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.net.util.SubnetUtils; + +@JinjavaDoc( + value = "Evaluates to true if the value is a valid IPv4 or IPv6 address", + input = @JinjavaParam( + value = "value", + type = "string", + desc = "String to check IP Address", + required = true + ), + params = { + @JinjavaParam( + value = "function", + type = "string", + defaultValue = "prefix", + desc = "Name of function. Supported functions: 'prefix', 'netmask', 'network', 'address', 'broadcast'" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example shows how to test if a string is a valid ip address", + code = "{% set ip = '1.0.0.1' %}\n" + + "{% if ip|ipaddr %}\n" + + " The string is a valid IP address\n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "This example shows how to filter list of ip addresses", + code = "{{ ['192.108.0.1', null, True, 13, '2000::'] | ipaddr }}", + output = "['192.108.0.1', '2000::']" + ), + } +) +public class IpAddrFilter implements Filter { + + private static final Pattern IP4_PATTERN = Pattern.compile( + "(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])" + ); + private static final Pattern IP6_PATTERN = Pattern.compile( + "^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$" + ); + private static final Pattern IP6_COMPRESSED_PATTERN = Pattern.compile( + "^((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)$" + ); + + private static final Splitter PREFIX_SPLITTER = Splitter.on('/'); + private static final String PREFIX_STRING = "prefix"; + private static final String NETMASK_STRING = "netmask"; + private static final String ADDRESS_STRING = "address"; + private static final String BROADCAST_STRING = "broadcast"; + private static final String NETWORK_STRING = "network"; + private static final String GATEWAY_STRING = "gateway"; + private static final String PUBLIC_STRING = "public"; + private static final String PRIVATE_STRING = "private"; + private static final String AVAILABLE_FUNCTIONS = Joiner + .on(", ") + .join( + PREFIX_STRING, + NETMASK_STRING, + ADDRESS_STRING, + BROADCAST_STRING, + NETWORK_STRING, + GATEWAY_STRING, + PUBLIC_STRING, + PRIVATE_STRING + ); + + @Override + public Object filter(Object object, JinjavaInterpreter interpreter, String... args) { + if (object == null) { + return false; + } + + String parameter = getParameter(args); + if (object instanceof Map) { + return getMapOfFilteredItems(interpreter, parameter, object); + } + + if (object instanceof Iterable) { + return getFilteredItems(interpreter, parameter, object); + } + + if (object instanceof String) { + return getValue(interpreter, object, parameter); + } + + return false; + } + + private String getParameter(String... args) { + if (args.length > 0) { + return args[0].trim(); + } + + return null; + } + + private Object getMapOfFilteredItems( + JinjavaInterpreter interpreter, + String parameter, + Object map + ) { + Iterator> iterator = ((Map) map).entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry item = iterator.next(); + + String filteredItem = filterItem(interpreter, parameter, item.getValue()); + if (filteredItem != null) { + item.setValue(filteredItem); + } else { + iterator.remove(); + } + } + + return map; + } + + private List getFilteredItems( + JinjavaInterpreter interpreter, + String parameter, + Object object + ) { + List filteredItems = new ArrayList<>(); + ForLoop loop = ObjectIterator.getLoop(object); + + while (loop.hasNext()) { + String filteredItem = filterItem(interpreter, parameter, loop.next()); + if (filteredItem != null) { + filteredItems.add(filteredItem); + } + } + + return filteredItems; + } + + private String filterItem( + JinjavaInterpreter interpreter, + String parameter, + Object item + ) { + Object value; + try { + value = getValue(interpreter, item, parameter); + } catch (InvalidArgumentException exception) { + return null; + } + + if (value instanceof String || value instanceof Integer) { + return String.valueOf(value); + } + + if (value instanceof Boolean && (Boolean) value) { + return (String) item; + } + + return null; + } + + private Object getValue( + JinjavaInterpreter interpreter, + Object object, + String parameter + ) { + if (!(object instanceof String)) { + return null; + } + + String fullAddress = ((String) object).trim(); + if (StringUtils.isEmpty(parameter)) { + return validIp(fullAddress); + } + + List parts = PREFIX_SPLITTER.splitToList(fullAddress); + if ( + parts.size() == 1 && + (parameter.equalsIgnoreCase(PUBLIC_STRING) || + parameter.equalsIgnoreCase(PRIVATE_STRING)) + ) { + parts = new ArrayList<>(parts); + parts.add("0"); + } + + if (parts.size() > 2) { + return null; + } + + String ipAddress = parts.get(0); + if (!validIp(ipAddress)) { + return null; + } + + if (parameter.equalsIgnoreCase(ADDRESS_STRING)) { + return ipAddress; + } + + if (parts.size() != 2) { + return null; + } + + String prefixString = parts.get(1); + Integer prefix; + try { + prefix = Integer.parseInt(prefixString); + } catch (NumberFormatException ex) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + prefixString + ); + } + + boolean isv4 = IP4_PATTERN.matcher(ipAddress).matches(); + switch (parameter) { + case PREFIX_STRING: + return prefix; + case NETMASK_STRING: + return isv4 + ? getSubnetUtils(interpreter, fullAddress).getInfo().getNetmask() + : getIpv6Network(interpreter, fullAddress).getNetmask().asAddress().toString(); + case BROADCAST_STRING: + return isv4 + ? getSubnetUtils(interpreter, fullAddress).getInfo().getBroadcastAddress() + : getIpv6Network(interpreter, fullAddress).getLast().toString(); + case NETWORK_STRING: + return isv4 + ? getSubnetUtils(interpreter, fullAddress).getInfo().getNetworkAddress() + : getIpv6Network(interpreter, fullAddress).toString().split("/")[0]; + case GATEWAY_STRING: + return isv4 + ? getSubnetUtils(interpreter, fullAddress).getInfo().getLowAddress() + : getIpv6Network(interpreter, fullAddress).getFirst().add(1).toString(); + case PUBLIC_STRING: + return !isIpAddressPrivate(getInetAddress(ipAddress), isv4); + case PRIVATE_STRING: + return isIpAddressPrivate(getInetAddress(ipAddress), isv4); + default: + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.ENUM, + 1, + parameter, + AVAILABLE_FUNCTIONS + ); + } + } + + private SubnetUtils getSubnetUtils(JinjavaInterpreter interpreter, String address) { + try { + return new SubnetUtils(address); + } catch (IllegalArgumentException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.CIDR, + 0, + address + ); + } + } + + private IPv6Network getIpv6Network(JinjavaInterpreter interpreter, String address) { + try { + return IPv6Network.fromString(address); + } catch (IllegalArgumentException e) { + throw new InvalidArgumentException(interpreter, this.getName(), e.getMessage()); + } + } + + private InetAddress getInetAddress(String ipAddress) { + try { + return InetAddress.getByName(ipAddress); + } catch (UnknownHostException e) { + return null; + } + } + + private boolean isIpAddressPrivate(InetAddress inetAddress, boolean isv4) { + if (inetAddress == null) { + return false; + } + + return isv4 + ? inetAddress.isSiteLocalAddress() + : inetAddress.isSiteLocalAddress() || + inetAddress.isLinkLocalAddress() || + inetAddress.isLoopbackAddress(); + } + + protected boolean validIp(String address) { + return validIpv4(address) || validIpv6(address); + } + + protected boolean validIpv4(String address) { + return IP4_PATTERN.matcher(address).matches(); + } + + protected boolean validIpv6(String address) { + return ( + IP6_PATTERN.matcher(address).matches() || + IP6_COMPRESSED_PATTERN.matcher(address).matches() + ); + } + + @Override + public boolean preserveSafeString() { + return false; + } + + @Override + public String getName() { + return "ipaddr"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Ipv4Filter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Ipv4Filter.java new file mode 100644 index 000000000..361b74fd7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Ipv4Filter.java @@ -0,0 +1,50 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; + +@JinjavaDoc( + value = "Evaluates to true if the value is a valid IPv4 address", + input = @JinjavaParam( + value = "value", + type = "string", + desc = "String to check IPv4 Address", + required = true + ), + params = { + @JinjavaParam( + value = "function", + type = "string", + defaultValue = "prefix", + desc = "Name of function. " + + "Supported functions: 'prefix', 'netmask', 'network', 'address', 'broadcast'" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example shows how to test if a string is a valid ipv4 address", + code = "{% set ip = '192.108.0.' %}\n" + + "{% if ip|ipv4 %}\n" + + " The string is a valid IPv4 address\n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "This example shows how to filter list of ipv4 addresses", + code = "{{ ['192.108.0.1', null, True, 13, '2000::'] | ipv4 }}", + output = "['192.108.0.']" + ), + } +) +public class Ipv4Filter extends IpAddrFilter { + + @Override + protected boolean validIp(String address) { + return validIpv4(address); + } + + @Override + public String getName() { + return "ipv4"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Ipv6Filter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Ipv6Filter.java new file mode 100644 index 000000000..2032f95f9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Ipv6Filter.java @@ -0,0 +1,50 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; + +@JinjavaDoc( + value = "Evaluates to true if the value is a valid IPv6 address", + input = @JinjavaParam( + value = "value", + type = "string", + desc = "String to check IPv6 Address", + required = true + ), + params = { + @JinjavaParam( + value = "function", + type = "string", + defaultValue = "prefix", + desc = "Name of function. " + + "Supported functions: 'prefix', 'netmask', 'network', 'address', 'broadcast'" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example shows how to test if a string is a valid ipv6 address", + code = "{% set ip = '2000::' %}\n" + + "{% if ip|ipv6 %}\n" + + " The string is a valid IPv6 address\n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "This example shows how to filter list of ipv6 addresses", + code = "{{ ['192.108.0.1', null, True, 13, '2000::'] | ipv6 }}", + output = "['2000::']" + ), + } +) +public class Ipv6Filter extends IpAddrFilter { + + @Override + protected boolean validIp(String address) { + return validIpv6(address); + } + + @Override + public String getName() { + return "ipv6"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/JoinFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/JoinFilter.java index e3718690e..853966e1e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/JoinFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/JoinFilter.java @@ -1,35 +1,42 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import com.google.common.base.Joiner; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; import com.hubspot.jinjava.util.ForLoop; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.Objects; @JinjavaDoc( - value = "Return a string which is the concatenation of the strings in the sequence.", - params = { - @JinjavaParam(value = "value", desc = "The values to join"), - @JinjavaParam(value = "d", desc = "The separator string used to join the items"), - @JinjavaParam(value = "attr", desc = "Optional dict object attribute to use in joining") - }, - snippets = { - @JinjavaSnippet( - code = "{{ [1, 2, 3]|join('|') }}", - output = "1|2|3"), - @JinjavaSnippet( - code = "{{ [1, 2, 3]|join }}", - output = "123"), - @JinjavaSnippet( - desc = "It is also possible to join certain attributes of an object", - code = "{{ users|join(', ', attribute='username') }}") - }) + value = "Return a string which is the concatenation of the strings in the sequence.", + input = @JinjavaParam(value = "value", desc = "The values to join", required = true), + params = { + @JinjavaParam( + value = "d", + desc = "The separator string used to join the items", + defaultValue = "(empty String)" + ), + @JinjavaParam( + value = "attr", + desc = "Optional dict object attribute to use in joining" + ), + }, + snippets = { + @JinjavaSnippet(code = "{{ [1, 2, 3]|join('|') }}", output = "1|2|3"), + @JinjavaSnippet(code = "{{ [1, 2, 3]|join }}", output = "123"), + @JinjavaSnippet( + desc = "It is also possible to join certain attributes of an object", + code = "{{ users|join(', ', attribute='username') }}" + ), + } +) public class JoinFilter implements Filter { @Override @@ -39,7 +46,9 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - List vals = new ArrayList<>(); + LengthLimitingStringBuilder stringBuilder = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxStringLength() + ); String separator = ""; if (args.length > 0) { @@ -52,6 +61,7 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) } ForLoop loop = ObjectIterator.getLoop(var); + boolean first = true; while (loop.hasNext()) { Object val = loop.next(); @@ -59,10 +69,35 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) val = interpreter.resolveProperty(val, attr); } - vals.add(Objects.toString(val, "")); + try { + if (!first) { + stringBuilder.append(separator); + } else { + first = false; + } + stringBuilder.append(Objects.toString(val, "")); + } catch (OutputTooBigException ex) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.OTHER, + ErrorItem.FILTER, + String.format( + "Result of %s filter has been truncated to the max String length of %d", + getName(), + interpreter.getConfig().getMaxStringLength() + ), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + ex + ) + ); + + return stringBuilder.toString(); + } } - return Joiner.on(separator).join(vals); + return stringBuilder.toString(); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/LastFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/LastFilter.java index fe8c7e2f4..51d197289 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/LastFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/LastFilter.java @@ -8,15 +8,20 @@ import com.hubspot.jinjava.util.ObjectIterator; @JinjavaDoc( - value = "Return the last item of a sequence", - params = { - @JinjavaParam(value = "seq", type = "sequence", desc = "Sequence to return last item from") - }, - snippets = { - @JinjavaSnippet( - code = "{% set my_sequence = ['Item 1', 'Item 2', 'Item 3'] %}\n" + - "{{ my_sequence|last }}") - }) + value = "Return the last item of a sequence", + input = @JinjavaParam( + value = "seq", + type = "sequence", + desc = "Sequence to return last item from", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set my_sequence = ['Item 1', 'Item 2', 'Item 3'] %}\n" + + "{{ my_sequence|last }}" + ), + } +) public class LastFilter implements Filter { @Override @@ -35,5 +40,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return last; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/LengthFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/LengthFilter.java index 80449fa42..28269113e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/LengthFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/LengthFilter.java @@ -15,24 +15,29 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import java.lang.reflect.Array; -import java.util.Collection; -import java.util.Iterator; -import java.util.Map; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.lang.reflect.Array; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; @JinjavaDoc( - value = "Return the number of items of a sequence or mapping", - params = @JinjavaParam(value = "object", desc = "The sequence to count"), - snippets = { - @JinjavaSnippet( - code = "{% set services = ['Web design', 'SEO', 'Inbound Marketing', 'PPC'] %}\n" + - "{{ services|length }}") - }) + value = "Return the number of items of a sequence or mapping", + input = @JinjavaParam( + value = "object", + desc = "The sequence to count", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set services = ['Web design', 'SEO', 'Inbound Marketing', 'PPC'] %}\n" + + "{{ services|length }}" + ), + } +) public class LengthFilter implements Filter { @Override @@ -79,9 +84,13 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar return 0; } + @Override + public boolean preserveSafeString() { + return false; + } + @Override public String getName() { return "length"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ListFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ListFilter.java index 9b90b2e54..764a3719d 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/ListFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ListFilter.java @@ -1,25 +1,33 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Collection; -import java.util.List; - import com.google.common.collect.Lists; +import com.google.common.primitives.Chars; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.apache.commons.lang3.ArrayUtils; @JinjavaDoc( - value = "Convert the value into a list. If it was a string the returned list will be a list of characters.", - params = @JinjavaParam(value = "value", desc = "Value to add to a sequence"), - snippets = { - @JinjavaSnippet( - code = "{% set one = 1 %}\n" + - "{% set two = 2 %}\n" + - "{% set three = 3 %}\n" + - "{% set list_num = one|list + two|list + three|list %}\n" + - "{{ list_num|list }}") - }) + value = "Convert the value into a list. If it was a string the returned list will be a list of characters.", + input = @JinjavaParam( + value = "value", + desc = "Value to add to a sequence", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set one = 1 %}\n" + + "{% set two = 2 %}\n" + + "{% set three = 3 %}\n" + + "{% set list_num = one|list + two|list + three|list %}\n" + + "{{ list_num|list }}" + ), + } +) public class ListFilter implements Filter { @Override @@ -31,19 +39,46 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { List result; - if (var instanceof String) { - result = Lists.newArrayList(((String) var).toCharArray()); + if (var == null) { + return null; } - else if (Collection.class.isAssignableFrom(var.getClass())) { + if (var instanceof String) { + result = Chars.asList(((String) var).toCharArray()); + } else if (Collection.class.isAssignableFrom(var.getClass())) { result = Lists.newArrayList((Collection) var); - } - - else { + } else if (var.getClass().isArray()) { + if (var instanceof boolean[]) { + Boolean[] outputBoxed = ArrayUtils.toObject((boolean[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof byte[]) { + Byte[] outputBoxed = ArrayUtils.toObject((byte[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof char[]) { + Character[] outputBoxed = ArrayUtils.toObject((char[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof short[]) { + Short[] outputBoxed = ArrayUtils.toObject((short[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof int[]) { + Integer[] outputBoxed = ArrayUtils.toObject((int[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof long[]) { + Long[] outputBoxed = ArrayUtils.toObject((long[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof float[]) { + Float[] outputBoxed = ArrayUtils.toObject((float[]) var); + result = Arrays.asList(outputBoxed); + } else if (var instanceof double[]) { + Double[] outputBoxed = ArrayUtils.toObject((double[]) var); + result = Arrays.asList(outputBoxed); + } else { + result = Arrays.asList((Object[]) var); + } + } else { result = Lists.newArrayList(var); } return result; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/LogFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/LogFilter.java new file mode 100644 index 000000000..23c879463 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/LogFilter.java @@ -0,0 +1,153 @@ +package com.hubspot.jinjava.lib.filter; + +import ch.obermuhlner.math.big.BigDecimalMath; +import com.google.common.primitives.Doubles; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.MathContext; +import java.math.RoundingMode; + +@JinjavaDoc( + value = "Return the natural log of the input.", + input = @JinjavaParam( + value = "number", + type = "number", + desc = "The number to get the log of", + required = true + ), + params = @JinjavaParam( + value = "base", + type = "number", + defaultValue = "e (natural logarithm)", + desc = "The base to use for the log calculation" + ), + snippets = { @JinjavaSnippet(code = "{{ 25|log(5) }}") } +) +public class LogFilter implements Filter { + + private static final MathContext PRECISION = new MathContext(50); + + @Override + public Object filter(Object object, JinjavaInterpreter interpreter, String... args) { + // default to e + Double root = null; + if (args.length > 0 && args[0] != null) { + Double tryRoot = Doubles.tryParse(args[0]); + if (tryRoot == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + args[0] + ); + } + + root = tryRoot; + } + + if (object instanceof Integer) { + return calculateLog(interpreter, (Integer) object, root); + } + if (object instanceof Float) { + return calculateLog(interpreter, (Float) object, root); + } + if (object instanceof Long) { + return calculateLog(interpreter, (Long) object, root); + } + if (object instanceof Short) { + return calculateLog(interpreter, (Short) object, root); + } + if (object instanceof Double) { + return calculateLog(interpreter, (Double) object, root); + } + if (object instanceof Byte) { + return calculateLog(interpreter, (Byte) object, root); + } + if (object instanceof BigDecimal) { + return calculateBigLog(interpreter, (BigDecimal) object, root); + } + if (object instanceof BigInteger) { + return calculateBigLog(interpreter, new BigDecimal((BigInteger) object), root); + } + if (object instanceof String) { + try { + return calculateBigLog(interpreter, new BigDecimal((String) object), root); + } catch (NumberFormatException e) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + object.toString() + ); + } + } + + return object; + } + + private double calculateLog(JinjavaInterpreter interpreter, double num, Double base) { + checkArguments(interpreter, num, base); + + if (base == null) { + return Math.log(num); + } + + return BigDecimalMath + .log(new BigDecimal(num), PRECISION) + .divide(BigDecimalMath.log(new BigDecimal(base), PRECISION), RoundingMode.HALF_EVEN) + .doubleValue(); + } + + private BigDecimal calculateBigLog( + JinjavaInterpreter interpreter, + BigDecimal num, + Double base + ) { + checkArguments(interpreter, num.doubleValue(), base); + + if (base == null) { + return BigDecimalMath.log(num, PRECISION); + } + + return BigDecimalMath + .log(num, PRECISION) + .divide( + BigDecimalMath.log(new BigDecimal(base), PRECISION), + RoundingMode.HALF_EVEN + ); + } + + private void checkArguments(JinjavaInterpreter interpreter, double num, Double base) { + if (num <= 0) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.POSITIVE_NUMBER, + num + ); + } + + if (base != null && base <= 0) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.POSITIVE_NUMBER, + 0, + base + ); + } + } + + @Override + public String getName() { + return "log"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/LowerFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/LowerFilter.java index 579f34a82..b694bd8ad 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/LowerFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/LowerFilter.java @@ -21,11 +21,10 @@ import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Convert a value to lowercase", - params = @JinjavaParam(value = "s", desc = "String to make lowercase"), - snippets = { - @JinjavaSnippet(code = "{{ \"Text to MAKE Lowercase\"|lowercase }}") - }) + value = "Convert a value to lowercase", + input = @JinjavaParam(value = "s", desc = "String to make lowercase", required = true), + snippets = { @JinjavaSnippet(code = "{{ \"Text to MAKE Lowercase\"|lower }}") } +) public class LowerFilter implements Filter { @Override @@ -41,5 +40,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "lower"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/MapFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/MapFilter.java index 0e4753a76..ec5db716a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/MapFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/MapFilter.java @@ -1,32 +1,49 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; @JinjavaDoc( - value = "Applies a filter on a sequence of objects or looks up an attribute.", - params = { - @JinjavaParam(value = "value", type = "object", desc = "Sequence to apply filter or dict to lookup attribute"), - @JinjavaParam(value = "attribute", desc = "Filter to apply to an object or dict attribute to lookup") - }, - snippets = { - @JinjavaSnippet( - desc = "The basic usage is mapping on an attribute. Imagine you have a list of users but you are only interested in a list of usernames", - code = "Users on this page: {{ users|map(attribute='username')|join(', ') }}"), - @JinjavaSnippet( - desc = "Alternatively you can let it invoke a filter by passing the name of the filter and the arguments afterwards. A good example would be applying a text conversion filter on a sequence", - code = "{% set seq = ['item1', 'item2', 'item3'] %}\n" + - "{{ seq|map('upper') }}") - }) -public class MapFilter implements Filter { + value = "Applies a filter on a sequence of objects or looks up an attribute.", + input = @JinjavaParam( + value = "value", + type = "object", + desc = "Sequence to apply filter or dict to lookup attribute", + required = true + ), + params = { + @JinjavaParam( + value = "attribute", + desc = "Filter to apply to an object or dict attribute to lookup", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + desc = "The basic usage is mapping on an attribute. Imagine you have a list of users but you are only interested in a list of usernames", + code = "Users on this page: {{ users|map(attribute='username')|join(', ') }}" + ), + @JinjavaSnippet( + desc = "Alternatively you can let it invoke a filter by passing the name of the filter and the arguments afterwards. A good example would be applying a text conversion filter on a sequence", + code = "{% set seq = ['item1', 'item2', 'item3'] %}\n" + "{{ seq|map('upper') }}" + ), + } +) +public class MapFilter implements AdvancedFilter { + + private static final String ATTRIBUTE_ARGUMENT = "attribute"; @Override public String getName() { @@ -34,24 +51,57 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { ForLoop loop = ObjectIterator.getLoop(var); - if (args.length == 0) { - throw new InterpretException(getName() + " filter requires name of filter or attribute to apply to given sequence"); + if (args.length < 1 && kwargs.size() < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (name of filter or attribute to apply to given sequence)" + ); } - String attr = args[0]; - Filter apply = interpreter.getContext().getFilter(attr); + String attr; + Filter apply = null; + if (kwargs.containsKey(ATTRIBUTE_ARGUMENT)) { + Object attrArg = kwargs.get(ATTRIBUTE_ARGUMENT); + if (attrArg == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NULL, + ATTRIBUTE_ARGUMENT + ); + } + attr = attrArg.toString(); + } else { + Object attrArg = args[0]; + if (attrArg == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 0); + } + attr = attrArg.toString(); + apply = interpreter.getContext().getFilter(attr); + } List result = new ArrayList<>(); while (loop.hasNext()) { Object val = loop.next(); if (apply != null) { - val = apply.filter(val, interpreter); - } - else { + val = + apply.filter( + val, + interpreter, + Arrays.copyOfRange(args, 1, args.length), + Collections.emptyMap() + ); + } else { val = interpreter.resolveProperty(val, attr); } @@ -60,5 +110,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return result; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/Md5Filter.java b/src/main/java/com/hubspot/jinjava/lib/filter/Md5Filter.java index 49f93ba25..9f1e54e00 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/Md5Filter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/Md5Filter.java @@ -17,24 +17,43 @@ import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; -import java.nio.charset.Charset; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; @JinjavaDoc( - value = "Calculates the md5 hash of the given object", - params = @JinjavaParam(value = "value", desc = "Value to get MD5 hash of"), - snippets = { - @JinjavaSnippet(code = "{{ content.absolute_url|md5 }}") - }) + value = "Calculates the md5 hash of the given object", + input = @JinjavaParam( + value = "value", + desc = "Value to get MD5 hash of", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ content.absolute_url|md5 }}") } +) public class Md5Filter implements Filter { - private static final String[] NOSTR = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" }; + private static final String[] NOSTR = { + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "a", + "b", + "c", + "d", + "e", + "f", + }; private static final String MD5 = "MD5"; private String byteToArrayString(byte bByte) { @@ -71,14 +90,18 @@ private String md5(String str, Charset encoding) { @Override public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { if (object instanceof String) { - return md5((String) object, interpreter.getConfiguration().getCharset()); + return md5((String) object, interpreter.getConfig().getCharset()); } return object; } + @Override + public boolean preserveSafeString() { + return false; + } + @Override public String getName() { return "md5"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/MinusTimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/MinusTimeFilter.java new file mode 100644 index 000000000..30b812c40 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/MinusTimeFilter.java @@ -0,0 +1,85 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.DateTimeException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +/** + * {@link ChronoUnit} for valid time units + */ +@JinjavaDoc( + value = "Subtracts a specified amount of time to a datetime object", + input = @JinjavaParam( + value = "var", + desc = "Datetime object or timestamp", + required = true + ), + params = { + @JinjavaParam( + value = "diff", + desc = "The amount to subtract from the datetime", + required = true + ), + @JinjavaParam(value = "unit", desc = "Which temporal unit to use", required = true), + }, + snippets = { @JinjavaSnippet(code = "{% mydatetime|minus_time(3, 'days') %}") } +) +public class MinusTimeFilter extends BaseDateFilter { + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + long diff = parseDiffAmount(interpreter, args); + ChronoUnit chronoUnit = parseChronoUnit(interpreter, args); + + try { + if (var instanceof ZonedDateTime) { + ZonedDateTime dateTime = (ZonedDateTime) var; + return new PyishDate(dateTime.minus(diff, chronoUnit)); + } else if (var instanceof PyishDate) { + PyishDate pyishDate = (PyishDate) var; + return new PyishDate(pyishDate.toDateTime().minus(diff, chronoUnit)); + } else if (var instanceof Number) { + Number timestamp = (Number) var; + ZonedDateTime zonedDateTime = Functions.getDateTimeArg(timestamp, ZoneOffset.UTC); + return new PyishDate(zonedDateTime.minus(diff, chronoUnit)); + } + } catch (DateTimeException e) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.OTHER, + ErrorItem.FILTER, + e.getMessage(), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + } + + return var; + } + + @Override + public String getName() { + return "minus_time"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/MultiplyFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/MultiplyFilter.java index 65c9ba6bb..269ecd2d2 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/MultiplyFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/MultiplyFilter.java @@ -15,73 +15,77 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.math.BigInteger; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.el.TruthyTypeConverter; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import de.odysseus.el.misc.NumberOperations; +import java.math.BigDecimal; +import java.util.Map; @JinjavaDoc( - value = "Multiplies the current object with the given multiplier", - params = { - @JinjavaParam(value = "value", type = "number", desc = "Base number to be multiplied"), - @JinjavaParam(value = "multiplier", type = "number", desc = "The multiplier") - }, - snippets = { - @JinjavaSnippet( - code = "{% set n = 20 %}\n" + - "{{ n|multiply(3) }}") - }) -public class MultiplyFilter implements Filter { + value = "Multiplies the current object with the given multiplier", + input = @JinjavaParam( + value = "value", + type = "number", + desc = "Base number to be multiplied", + required = true + ), + params = { + @JinjavaParam( + value = "multiplier", + type = "number", + desc = "The multiplier", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{% set n = 20 %}\n" + "{{ n|multiply(3) }}") } +) +public class MultiplyFilter implements AdvancedFilter { - @Override - public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - if (arg.length != 1) { - throw new InterpretException("filter multiply expects 1 arg >>> " + arg.length); - } - String toMul = arg[0]; - Number num = new BigDecimal(toMul); + private static final TruthyTypeConverter TYPE_CONVERTER = new TruthyTypeConverter(); - if (object instanceof Integer) { - return num.intValue() * (Integer) object; - } - if (object instanceof Float) { - return 0D + num.floatValue() * (Float) object; - } - if (object instanceof Long) { - return num.longValue() * (Long) object; - } - if (object instanceof Short) { - return 0 + num.shortValue() * (Short) object; - } - if (object instanceof Double) { - return num.doubleValue() * (Double) object; - } - if (object instanceof BigDecimal) { - return ((BigDecimal) object).multiply(BigDecimal.valueOf(num.doubleValue())); - } - if (object instanceof BigInteger) { - return ((BigInteger) object).multiply(BigInteger.valueOf(num.longValue())); - } - if (object instanceof Byte) { - return num.byteValue() * (Byte) object; + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (number to multiply by)" + ); } - if (object instanceof String) { + Object toMul = args[0]; + Number num; + if (toMul != null) { try { - return num.doubleValue() * Double.parseDouble((String) object); - } catch (Exception e) { - throw new InterpretException(object + " can't be dealed with multiply filter", e); + num = new BigDecimal(toMul.toString()); + } catch (NumberFormatException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + toMul + ); } + } else { + return var; } - return object; + + return NumberOperations.mul(TYPE_CONVERTER, var, num); } @Override public String getName() { return "multiply"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/PlusTimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/PlusTimeFilter.java new file mode 100644 index 000000000..733e4818f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/PlusTimeFilter.java @@ -0,0 +1,84 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.DateTimeException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +/** + * {@link ChronoUnit} for valid time units + */ +@JinjavaDoc( + value = "Adds a specified amount of time to a datetime object", + input = @JinjavaParam( + value = "var", + desc = "Datetime object or timestamp", + required = true + ), + params = { + @JinjavaParam( + value = "diff", + desc = "The amount to add to the datetime", + required = true + ), + @JinjavaParam(value = "unit", desc = "Which temporal unit to use", required = true), + }, + snippets = { @JinjavaSnippet(code = "{% mydatetime|plus_time(3, 'days') %}") } +) +public class PlusTimeFilter extends BaseDateFilter { + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + long diff = parseDiffAmount(interpreter, args); + ChronoUnit chronoUnit = parseChronoUnit(interpreter, args); + + try { + if (var instanceof ZonedDateTime) { + ZonedDateTime dateTime = (ZonedDateTime) var; + return new PyishDate(dateTime.plus(diff, chronoUnit)); + } else if (var instanceof PyishDate) { + PyishDate pyishDate = (PyishDate) var; + return new PyishDate(pyishDate.toDateTime().plus(diff, chronoUnit)); + } else if (var instanceof Number) { + Number timestamp = (Number) var; + ZonedDateTime zonedDateTime = Functions.getDateTimeArg(timestamp, ZoneOffset.UTC); + return new PyishDate(zonedDateTime.plus(diff, chronoUnit)); + } + } catch (DateTimeException e) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.OTHER, + ErrorItem.FILTER, + e.getMessage(), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + } + return var; + } + + @Override + public String getName() { + return "plus_time"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilter.java index 2fdce9065..8132aaeae 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilter.java @@ -1,34 +1,32 @@ package com.hubspot.jinjava.lib.filter; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - -import java.beans.BeanInfo; -import java.beans.IntrospectionException; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; -import java.lang.reflect.Method; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import org.apache.commons.lang3.StringEscapeUtils; -import org.apache.commons.lang3.StringUtils; - +import com.fasterxml.jackson.core.JsonProcessingException; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.objects.date.PyishDate; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; @JinjavaDoc( - value = "Pretty print a variable. Useful for debugging.", - params = @JinjavaParam(value = "value", type = "object", desc = "Object to Pretty Print"), - snippets = { - @JinjavaSnippet( - code = "{% set this_var =\"Variable that I want to debug\" %}\n" + - "{{ this_var|pprint }}") - }) + value = "Pretty print a variable. Useful for debugging.", + input = @JinjavaParam( + value = "value", + type = "object", + desc = "Object to Pretty Print", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set this_var =\"Variable that I want to debug\" %}\n" + + "{{ this_var|pprint }}" + ), + } +) public class PrettyPrintFilter implements Filter { @Override @@ -42,44 +40,33 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return "null"; } - String varStr = null; + String varStr; - if (var instanceof String || var instanceof Number || var instanceof PyishDate || var instanceof Iterable || var instanceof Map) { + if ( + var instanceof String || + var instanceof Number || + var instanceof PyishDate || + var instanceof Iterable + ) { varStr = Objects.toString(var); - } - else { - varStr = objPropsToString(var); - } - - return StringEscapeUtils.escapeHtml4("{% raw %}(" + var.getClass().getSimpleName() + ": " + varStr + "){% endraw %}"); - } - - private String objPropsToString(Object var) { - List props = new LinkedList<>(); - - try { - BeanInfo beanInfo = Introspector.getBeanInfo(var.getClass()); - - for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) { - try { - if (pd.getPropertyType().equals(Class.class)) { - continue; - } - - Method readMethod = pd.getReadMethod(); - if (readMethod != null && !readMethod.getDeclaringClass().equals(Object.class)) { - props.add(pd.getName() + "=" + readMethod.invoke(var)); - } - } catch (Exception e) { - ENGINE_LOG.error("Error reading bean value", e); - } + } else if (var instanceof Map) { + TreeMap map = new TreeMap((Map) var); + varStr = Objects.toString(map); + } else { + try { + varStr = + interpreter + .getConfig() + .getObjectMapper() + .writerWithDefaultPrettyPrinter() + .writeValueAsString(var); + } catch (JsonProcessingException e) { + throw new InvalidInputException(interpreter, this, InvalidReason.JSON_WRITE); } - - } catch (IntrospectionException e) { - ENGINE_LOG.error("Error inspecting bean", e); } - return '{' + StringUtils.join(props, ", ") + '}'; + return EscapeFilter.escapeHtmlEntities( + "{% raw %}(" + var.getClass().getSimpleName() + ": " + varStr + "){% endraw %}" + ); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RandomFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RandomFilter.java index 3042f74ac..1a4fef65f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/RandomFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RandomFilter.java @@ -15,28 +15,33 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.lang.reflect.Array; import java.math.BigDecimal; import java.util.Collection; import java.util.Iterator; import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; - -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaParam; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Return a random item from the sequence.", - params = @JinjavaParam(value = "seq", type = "sequence", desc = "Sequence to return a random item from"), - snippets = { - @JinjavaSnippet( - desc = "The example below is a standard blog loop that returns a single random post.", - code = "{% for content in contents|random %}\n" + - "
Post item markup
" + - "{% endfor %}") - }) + value = "Return a random item from the sequence.", + input = @JinjavaParam( + value = "seq", + type = "sequence", + desc = "Sequence to return a random item from", + required = true + ), + snippets = { + @JinjavaSnippet( + desc = "The example below is a standard blog loop that returns a single random post.", + code = "{% for content in contents|random %}\n" + + "
Post item markup
" + + "{% endfor %}" + ), + } +) public class RandomFilter implements Filter { @Override @@ -44,15 +49,16 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar if (object == null) { return null; } + // collection if (object instanceof Collection) { Collection clt = (Collection) object; - Iterator it = clt.iterator(); int size = clt.size(); if (size == 0) { return null; } - int index = ThreadLocalRandom.current().nextInt(size); + Iterator it = clt.iterator(); + int index = interpreter.getRandom().nextInt(size); while (index-- > 0) { it.next(); } @@ -64,18 +70,18 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar if (size == 0) { return null; } - int index = ThreadLocalRandom.current().nextInt(size); + int index = interpreter.getRandom().nextInt(size); return Array.get(object, index); } // map if (object instanceof Map) { Map map = (Map) object; - Iterator it = map.values().iterator(); int size = map.size(); if (size == 0) { return null; } - int index = ThreadLocalRandom.current().nextInt(size); + Iterator it = map.values().iterator(); + int index = interpreter.getRandom().nextInt(size); while (index-- > 0) { it.next(); } @@ -83,12 +89,14 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar } // number if (object instanceof Number) { - return ThreadLocalRandom.current().nextLong(((Number) object).longValue()); + return interpreter.getRandom().nextInt(((Number) object).intValue()); } // string if (object instanceof String) { try { - return ThreadLocalRandom.current().nextLong(new BigDecimal((String) object).longValue()); + return interpreter + .getRandom() + .nextInt(new BigDecimal((String) object).intValue()); } catch (Exception e) { return 0; } @@ -101,5 +109,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "random"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilter.java new file mode 100644 index 000000000..387cf78ab --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilter.java @@ -0,0 +1,101 @@ +package com.hubspot.jinjava.lib.filter; + +import static com.hubspot.jinjava.lib.filter.ReplaceFilter.checkLength; + +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; + +@JinjavaDoc( + value = "Return a copy of the value with all occurrences of a matched regular expression (Java RE2 syntax) " + + "replaced with a new one. The first argument is the regular expression to be matched, the second " + + "is the replacement string", + input = @JinjavaParam( + value = "s", + desc = "Base string to find and replace within", + required = true + ), + params = { + @JinjavaParam( + value = "regex", + desc = "The regular expression that you want to match and replace", + required = true + ), + @JinjavaParam( + value = "new", + desc = "The new string that you replace the matched substring", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{{ \"It costs $300\"|regex_replace(\"[^a-zA-Z]\", \"\") }}", + output = "Itcosts" + ), + } +) +public class RegexReplaceFilter implements Filter { + + @Override + public String getName() { + return "regex_replace"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length < 2) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 2 arguments (regex string, replacement string)" + ); + } + + if (args[0] == null || args[1] == null) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires both a valid regex and new params (not null)" + ); + } + + if (var == null) { + return null; + } + + if (var instanceof String) { + String s = (String) var; + // Minor optimization, avoid checking short strings + if (s.length() > 100) { + checkLength(interpreter, s, this); + } + String toReplace = args[0]; + String replaceWith = args[1]; + + try { + Pattern p = Pattern.compile(toReplace); + Matcher matcher = p.matcher(s); + + return matcher.replaceAll(replaceWith); + } catch (PatternSyntaxException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.REGEX, + 0, + toReplace + ); + } + } else { + throw new InvalidInputException(interpreter, this, InvalidReason.STRING); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RejectAttrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RejectAttrFilter.java index 10bdf5ef3..c4c780a64 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/RejectAttrFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RejectAttrFilter.java @@ -1,33 +1,43 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.exptest.ExpTest; -import com.hubspot.jinjava.util.ForLoop; -import com.hubspot.jinjava.util.ObjectIterator; +import java.util.Map; @JinjavaDoc( - value = "Filters a sequence of objects by applying a test to an attribute of an object or the attribute and " - + "rejecting the ones with the test succeeding.", - params = { - @JinjavaParam(value = "seq", type = "sequence", desc = "Sequence to test"), - @JinjavaParam(value = "attribute", desc = "Attribute to test for and reject items that contain it"), - @JinjavaParam(value = "exp_test", type = "name of expression test", defaultValue = "truthy", desc = "Specify which expression test to run for making the rejection") - }, - snippets = { - @JinjavaSnippet( - desc = "This loop would reject any post containing content.post_list_summary_featured_image", - code = "{% for content in contents|rejectattr('post_list_summary_featured_image') %}\n" + - "
Post in listing markup
\n" + - "{% endfor %}") - }) -public class RejectAttrFilter implements Filter { + value = "Filters a sequence of objects by applying a test to an attribute of an object or the attribute and " + + "rejecting the ones with the test succeeding.", + input = @JinjavaParam( + value = "seq", + type = "sequence", + desc = "Sequence to test", + required = true + ), + params = { + @JinjavaParam( + value = "attribute", + desc = "Attribute to test for and reject items that contain it", + required = true + ), + @JinjavaParam( + value = "exp_test", + type = "name of expression test", + defaultValue = "truthy", + desc = "Specify which expression test to run for making the rejection" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This loop would reject any post containing content.post_list_summary_featured_image", + code = "{% for content in contents|rejectattr('post_list_summary_featured_image') %}\n" + + "
Post in listing markup
\n" + + "{% endfor %}" + ), + } +) +public class RejectAttrFilter extends SelectAttrFilter implements AdvancedFilter { @Override public String getName() { @@ -35,34 +45,12 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - List result = new ArrayList<>(); - - if (args.length == 0) { - throw new InterpretException(getName() + " filter requires an attr to filter on", interpreter.getLineNumber()); - } - - String attr = args[0]; - - ExpTest expTest = interpreter.getContext().getExpTest("truthy"); - if (args.length > 1) { - expTest = interpreter.getContext().getExpTest(args[1]); - if (expTest == null) { - throw new InterpretException("No expression test defined with name '" + args[1] + "'", interpreter.getLineNumber()); - } - } - - ForLoop loop = ObjectIterator.getLoop(var); - while (loop.hasNext()) { - Object val = loop.next(); - Object attrVal = interpreter.resolveProperty(val, attr); - - if (!expTest.evaluate(attrVal, interpreter)) { - result.add(val); - } - } - - return result; + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + return applyFilter(var, interpreter, args, kwargs, false); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RejectFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RejectFilter.java index 6808f74d0..d14f6cfd7 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/RejectFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RejectFilter.java @@ -1,29 +1,30 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.lib.exptest.ExpTest; -import com.hubspot.jinjava.util.ForLoop; -import com.hubspot.jinjava.util.ObjectIterator; @JinjavaDoc( - value = "Filters a sequence of objects by applying a test to the object and rejecting the ones with the test succeeding.", - params = { - @JinjavaParam(value = "seq", type = "Sequence to test"), - @JinjavaParam(value = "exp_test", type = "name of expression test", defaultValue = "truthy", desc = "Specify which expression test to run for making the selection") - }, - snippets = { - @JinjavaSnippet( - code = "{% set some_numbers = [10, 12, 13, 3, 5, 17, 22] %}\n" + - "{% some_numbers|reject('even') %}") - }) -public class RejectFilter implements Filter { + value = "Filters a sequence of objects by applying a test to the object and rejecting the ones with the test succeeding.", + input = @JinjavaParam(value = "seq", type = "Sequence to test", required = true), + params = { + @JinjavaParam( + value = "exp_test", + type = "name of expression test", + desc = "Specify which expression test to run for making the selection", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set some_numbers = [10, 12, 13, 3, 5, 17, 22] %}\n" + + "{% some_numbers|reject('even') %}" + ), + } +) +public class RejectFilter extends SelectFilter { @Override public String getName() { @@ -31,28 +32,12 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - List result = new ArrayList<>(); - - if (args.length == 0) { - throw new InterpretException(getName() + " requires an exp test to filter on", interpreter.getLineNumber()); - } - - ExpTest expTest = interpreter.getContext().getExpTest(args[0]); - if (expTest == null) { - throw new InterpretException("No exp test defined for name '" + args[0] + "'", interpreter.getLineNumber()); - } - - ForLoop loop = ObjectIterator.getLoop(var); - while (loop.hasNext()) { - Object val = loop.next(); - - if (!expTest.evaluate(val, interpreter)) { - result.add(val); - } - } - - return result; + boolean evaluate( + JinjavaInterpreter interpreter, + Object[] expArgs, + ExpTest expTest, + Object val + ) { + return !super.evaluate(interpreter, expArgs, expTest, val); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RenderFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RenderFilter.java new file mode 100644 index 000000000..3cdf41db7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RenderFilter.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.ext.DeferredInvocationResolutionException; +import com.hubspot.jinjava.el.ext.eager.RenderFlatTempVariable; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.math.NumberUtils; + +@JinjavaDoc( + value = "Renders a template string early to be used by other filters and functions", + input = @JinjavaParam(value = "s", desc = "String to render", required = true), + snippets = { + @JinjavaSnippet( + code = "{{ \"{% if my_val %} Hello {% else %} world {% endif %}\"|render }}" + ), + } +) +public class RenderFilter implements Filter { + + @Override + public String getName() { + return "render"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + int numDeferredTokensStart = interpreter.getContext().getDeferredTokens().size(); + String result; + if (args.length > 0) { + String firstArg = args[0]; + result = + interpreter.renderFlat( + Objects.toString(var), + NumberUtils.toLong(firstArg, interpreter.getConfig().getMaxOutputSize()) + ); + } else { + result = interpreter.renderFlat(Objects.toString(var)); + } + if (interpreter.getContext().getDeferredTokens().size() > numDeferredTokensStart) { + String tempVarName = RenderFlatTempVariable.getVarName(result); + interpreter + .getContext() + .getParent() + .put(tempVarName, new RenderFlatTempVariable(result)); + throw new DeferredInvocationResolutionException(tempVarName); + } + return result; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ReplaceFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ReplaceFilter.java index 58e592a5c..dd0a0927e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/ReplaceFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ReplaceFilter.java @@ -1,32 +1,53 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.Importable; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Return a copy of the value with all occurrences of a substring replaced with a new one. " + - "The first argument is the substring that should be replaced, the second is the replacement " + - "string. If the optional third argument count is given, only the first count occurrences are replaced", - params = { - @JinjavaParam(value = "s", desc = "Base string to find and replace within"), - @JinjavaParam(value = "old", desc = "The old substring that you want to match and replace"), - @JinjavaParam(value = "new", desc = "The new string that you replace the matched substring"), - @JinjavaParam(value = "count", type = "number", desc = "Replace only the first N occurrences") - }, - snippets = { - @JinjavaSnippet( - code = "{{ \"Hello World\"|replace(\"Hello\", \"Goodbye\") }}", - output = "Goodbye World"), - @JinjavaSnippet( - code = "{{ \"aaaaargh\"|replace(\"a\", \"d'oh, \", 2) }}", - output = "d'oh, d'oh, aaargh") - }) + value = "Return a copy of the value with all occurrences of a substring replaced with a new one. " + + "The first argument is the substring that should be replaced, the second is the replacement " + + "string. If the optional third argument count is given, only the first count occurrences are replaced", + input = @JinjavaParam( + value = "s", + desc = "Base string to find and replace within", + required = true + ), + params = { + @JinjavaParam( + value = "old", + desc = "The old substring that you want to match and replace", + required = true + ), + @JinjavaParam( + value = "new", + desc = "The new string that you replace the matched substring", + required = true + ), + @JinjavaParam( + value = "count", + type = "number", + desc = "Replace only the first N occurrences" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{{ \"Hello World\"|replace(\"Hello\", \"Goodbye\") }}", + output = "Goodbye World" + ), + @JinjavaSnippet( + code = "{{ \"aaaaargh\"|replace(\"a\", \"d'oh, \", 2) }}", + output = "d'oh, d'oh, aaargh" + ), + } +) public class ReplaceFilter implements Filter { @Override @@ -35,17 +56,24 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, - String... args) { - + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { if (var == null) { - throw new InterpretException("filter " + getName() + " requires a var to operate on"); + return null; } if (args.length < 2) { - throw new InterpretException("filter " + getName() + " requires two string args"); + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 2 arguments (substring to replace, replacement string) or 3 arguments (substring to replace, replacement string, number of occurrences to replace)" + ); + } + + String s = var.toString(); + // Minor optimization, avoid checking short strings + if (s.length() > 100) { + checkLength(interpreter, s, this); } - String s = (String) var; String toReplace = args[0]; String replaceWith = args[1]; Integer count = null; @@ -56,10 +84,25 @@ public Object filter(Object var, JinjavaInterpreter interpreter, if (count == null) { return StringUtils.replace(s, toReplace, replaceWith); - } - else { + } else { return StringUtils.replace(s, toReplace, replaceWith, count); } } + static void checkLength( + JinjavaInterpreter interpreter, + String s, + Importable importable + ) { + long maxStringLength = interpreter.getConfig().getMaxStringLength(); + if (maxStringLength > 0 && s.length() > maxStringLength) { + throw new InvalidInputException( + interpreter, + importable, + InvalidReason.LENGTH, + s.length(), + maxStringLength + ); + } + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ReverseFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ReverseFilter.java index 31d431ada..53ad2d0a3 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/ReverseFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ReverseFilter.java @@ -15,26 +15,35 @@ **********************************************************************/ package com.hubspot.jinjava.lib.filter; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - -import java.lang.reflect.Array; -import java.util.Collection; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.collections.ArrayBacked; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; @JinjavaDoc( - value = "Reverse the object or return an iterator the iterates over it the other way round.", - params = @JinjavaParam(value = "value", type = "object", desc = "The sequence or dict to reverse the iteration order"), - snippets = { - @JinjavaSnippet( - code = "{% set nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] %}\n" + - "{% for num in nums|reverse %}\n" + - " {{ num }}\n" + - "{% endfor %}") - }) + value = "Reverse the object or return an iterator the iterates over it the other way round.", + input = @JinjavaParam( + value = "value", + type = "object", + desc = "The sequence or dict to reverse the iteration order", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] %}\n" + + "{% for num in nums|reverse %}\n" + + " {{ num }}\n" + + "{% endfor %}" + ), + } +) public class ReverseFilter implements Filter { @Override @@ -44,24 +53,14 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar } // collection if (object instanceof Collection) { - Object[] origin = ((Collection) object).toArray(); - int length = origin.length; - Object[] res = new Object[length]; - length--; - for (int i = 0; i <= length; i++) { - res[i] = origin[length - i]; - } - return res; + return maybeCollectToList( + ReverseArrayIterator.create(((Collection) object).toArray()), + interpreter + ); } // array if (object.getClass().isArray()) { - int length = Array.getLength(object); - Object[] res = new Object[length]; - length--; - for (int i = 0; i <= length; i++) { - res[i] = Array.get(object, length - i); - } - return res; + return maybeCollectToList(ReverseArrayIterator.create(object), interpreter); } // string if (object instanceof String) { @@ -74,13 +73,59 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar } return String.valueOf(res); } - ENGINE_LOG.warn("filter contain can't be applied to >>> " + object.getClass().getName()); + return object; } + private Object maybeCollectToList( + ReverseArrayIterator iterator, + JinjavaInterpreter interpreter + ) { + if (interpreter.getConfig().getLegacyOverrides().isIteratorOnlyReverseFilter()) { + return iterator; + } + List result = new ArrayList<>(); + while (iterator.hasNext()) { + result.add(iterator.next()); + } + return result; + } + @Override public String getName() { return "reverse"; } + static class ReverseArrayIterator implements Iterator, ArrayBacked { + + private final Object array; + private int index; + + static ReverseArrayIterator create(Object array) { + return new ReverseArrayIterator(array); + } + + private ReverseArrayIterator(Object array) { + this.array = array; + index = Array.getLength(array) - 1; + } + + @Override + public Object next() { + if (index < 0) { + throw new NoSuchElementException(); + } + return Array.get(array, index--); + } + + @Override + public boolean hasNext() { + return index >= 0; + } + + @Override + public Object backingArray() { + return array; + } + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RootFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RootFilter.java new file mode 100644 index 000000000..e6b718add --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RootFilter.java @@ -0,0 +1,147 @@ +package com.hubspot.jinjava.lib.filter; + +import ch.obermuhlner.math.big.BigDecimalMath; +import com.google.common.primitives.Doubles; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.MathContext; + +@JinjavaDoc( + value = "Return the square root of the input.", + input = @JinjavaParam( + value = "number", + type = "number", + desc = "The number to get the root of", + required = true + ), + params = @JinjavaParam( + value = "root", + type = "number", + defaultValue = "2", + desc = "The nth root to use for the calculation" + ), + snippets = { @JinjavaSnippet(code = "{{ 125|root(3) }}") } +) +public class RootFilter implements Filter { + + private static final MathContext PRECISION = new MathContext(50); + + @Override + public Object filter(Object object, JinjavaInterpreter interpreter, String... args) { + double root = 2; + if (args.length > 0 && args[0] != null) { + Double tryRoot = Doubles.tryParse(args[0]); + if (tryRoot == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + 0, + args[0] + ); + } + + root = tryRoot; + } + + if (object instanceof Integer) { + return calculateRoot(interpreter, (Integer) object, root); + } + if (object instanceof Float) { + return calculateRoot(interpreter, (Float) object, root); + } + if (object instanceof Long) { + return calculateRoot(interpreter, (Long) object, root); + } + if (object instanceof Short) { + return calculateRoot(interpreter, (Short) object, root); + } + if (object instanceof Double) { + return calculateRoot(interpreter, (Double) object, root); + } + if (object instanceof Byte) { + return calculateRoot(interpreter, (Byte) object, root); + } + if (object instanceof BigDecimal) { + return calculateBigRoot(interpreter, (BigDecimal) object, root); + } + if (object instanceof BigInteger) { + return calculateBigRoot(interpreter, new BigDecimal((BigInteger) object), root); + } + if (object instanceof String) { + try { + return calculateBigRoot(interpreter, new BigDecimal((String) object), root); + } catch (NumberFormatException e) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + object.toString() + ); + } + } + + return object; + } + + @Override + public String getName() { + return "root"; + } + + private double calculateRoot(JinjavaInterpreter interpreter, double num, double root) { + checkArguments(interpreter, num, root); + + if (root == 2) { + return Math.sqrt(num); + } else if (root == 3) { + return Math.cbrt(num); + } + + return BigDecimalMath + .root(new BigDecimal(num), new BigDecimal(root), PRECISION) + .doubleValue(); + } + + private BigDecimal calculateBigRoot( + JinjavaInterpreter interpreter, + BigDecimal num, + double root + ) { + checkArguments(interpreter, num.doubleValue(), root); + + if (root == 2) { + return BigDecimalMath.sqrt(num, PRECISION); + } + + return BigDecimalMath.root(num, new BigDecimal(root), PRECISION); + } + + private void checkArguments(JinjavaInterpreter interpreter, double num, double root) { + if (num <= 0) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.POSITIVE_NUMBER, + num + ); + } + + if (root <= 0) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.POSITIVE_NUMBER, + 0, + root + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RoundFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RoundFilter.java index 28ab56928..0110a8309 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/RoundFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RoundFilter.java @@ -1,28 +1,51 @@ package com.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.Objects; - -import org.apache.commons.lang3.math.NumberUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Round the number to a given precision.", - params = { - @JinjavaParam(value = "value", type = "number", desc = "The number to round"), - @JinjavaParam(value = "precision", type = "number", defaultValue = "0", desc = "Specifies the precision of rounding"), - @JinjavaParam(value = "method", type = "enum common|ceil|floor", defaultValue = "common", desc = "Method of rounding: 'common' rounds either up or down, 'ceil' always rounds up, and 'floor' always rounds down.") - }, - snippets = { - @JinjavaSnippet(code = "{{ 42.55|round }}", output = "43.0", desc = "Note that even if rounded to 0 precision, a float is returned."), - @JinjavaSnippet(code = "{{ 42.55|round(1, 'floor') }}", output = "42.5"), - @JinjavaSnippet(code = "{{ 42.55|round|int }}", output = "43", desc = "If you need a real integer, pipe it through int") - }) + value = "Round the number to a given precision.", + input = @JinjavaParam( + value = "value", + type = "number", + desc = "The number to round", + required = true + ), + params = { + @JinjavaParam( + value = "precision", + type = "number", + defaultValue = "0", + desc = "Specifies the precision of rounding" + ), + @JinjavaParam( + value = "method", + type = "enum common|ceil|floor", + defaultValue = "common", + desc = "Method of rounding: 'common' rounds either up or down, 'ceil' always rounds up, and 'floor' always rounds down." + ), + }, + snippets = { + @JinjavaSnippet( + code = "{{ 42.55|round }}", + output = "43.0", + desc = "Note that even if rounded to 0 precision, a float is returned." + ), + @JinjavaSnippet(code = "{{ 42.55|round(1, 'floor') }}", output = "42.5"), + @JinjavaSnippet( + code = "{{ 42.55|round|int }}", + output = "43", + desc = "If you need a real integer, pipe it through int" + ), + } +) public class RoundFilter implements Filter { @Override @@ -32,37 +55,46 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - BigDecimal result = BigDecimal.ZERO; + if (var == null) { + return null; + } + + BigDecimal result; try { - result = new BigDecimal(Objects.toString(var)); + result = new BigDecimal(var.toString()); } catch (NumberFormatException e) { + throw new InvalidInputException( + interpreter, + this, + InvalidReason.NUMBER_FORMAT, + var.toString() + ); } int precision = 0; - if (args.length > 0) { + if (args.length > 0 && args[0] != null) { precision = NumberUtils.toInt(args[0]); } String method = "common"; - if (args.length > 1) { + if (args.length > 1 && args[1] != null) { method = args[1]; } RoundingMode roundingMode; switch (method) { - case "ceil": - roundingMode = RoundingMode.CEILING; - break; - case "floor": - roundingMode = RoundingMode.FLOOR; - break; - case "common": - default: - roundingMode = RoundingMode.HALF_UP; + case "ceil": + roundingMode = RoundingMode.CEILING; + break; + case "floor": + roundingMode = RoundingMode.FLOOR; + break; + case "common": + default: + roundingMode = RoundingMode.HALF_UP; } return result.setScale(precision, roundingMode); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SafeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SafeFilter.java index 844d23185..6e22c5b23 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SafeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SafeFilter.java @@ -1,20 +1,19 @@ package com.hubspot.jinjava.lib.filter; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.SafeString; /** * Mark the value as safe which means that in an environment with automatic escaping enabled this variable will not be escaped. - * - * This is currently implemented as a pass-through for the given variable. - * */ @JinjavaDoc( - value = "Mark the value as safe, which means that in an environment with automatic escaping enabled this variable will not be escaped.", - snippets = { - @JinjavaSnippet(code = "{{ content.post_list_content|safe }}") - }) + value = "Mark the value as safe, which means that in an environment with automatic escaping enabled this variable will not be escaped.", + input = @JinjavaParam(value = "value", desc = "Value to mark as safe", required = true), + snippets = { @JinjavaSnippet(code = "{{ content.post_list_content|safe }}") } +) public class SafeFilter implements Filter { @Override @@ -23,9 +22,15 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, - String... args) { - return var; - } + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + return null; + } + + if (!(var instanceof String)) { + return var; + } + return new SafeString((String) var); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SelectAttrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SelectAttrFilter.java index 7e07478d8..8ee249135 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SelectAttrFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SelectAttrFilter.java @@ -1,32 +1,52 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.lib.exptest.ExpTest; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import com.hubspot.jinjava.util.Variable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; @JinjavaDoc( - value = "Filters a sequence of objects by applying a test to an attribute of an object and only selecting the ones with the test succeeding.", - params = { - @JinjavaParam(value = "sequence", type = "sequence", desc = "Sequence to test"), - @JinjavaParam(value = "attr", desc = "Attribute to test for and select items that contain it"), - @JinjavaParam(value = "exp_test", type = "name of expression test", defaultValue = "truthy", desc = "Specify which expression test to run for making the selection") - }, - snippets = { - @JinjavaSnippet( - desc = "This loop would select any post containing content.post_list_summary_featured_image", - code = "{% for content in contents|selectattr('post_list_summary_featured_image') %}\n" + - "
Post in listing markup
\n" + - "{% endfor %}") - }) -public class SelectAttrFilter implements Filter { + value = "Filters a sequence of objects by applying a test to an attribute of an object and only selecting the ones with the test succeeding.", + input = @JinjavaParam( + value = "sequence", + type = "sequence", + desc = "Sequence to test", + required = true + ), + params = { + @JinjavaParam( + value = "attr", + desc = "Attribute to test for and select items that contain it", + required = true + ), + @JinjavaParam( + value = "exp_test", + type = "name of expression test", + defaultValue = "truthy", + desc = "Specify which expression test to run for making the selection" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This loop would select any post containing content.post_list_summary_featured_image", + code = "{% for content in contents|selectattr('post_list_summary_featured_image') %}\n" + + "
Post in listing markup
\n" + + "{% endfor %}" + ), + } +) +public class SelectAttrFilter implements AdvancedFilter { @Override public String getName() { @@ -34,34 +54,77 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + return applyFilter(var, interpreter, args, kwargs, true); + } + + protected Object applyFilter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs, + boolean acceptObjects + ) { List result = new ArrayList<>(); - if (args.length == 0) { - throw new InterpretException(getName() + " filter requires an attr to filter on", interpreter.getLineNumber()); + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires at least 1 argument (attr to filter on)" + ); } - String attr = args[0]; + if (args[0] == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 0); + } + + String attr = args[0].toString(); + + Object[] expArgs = new String[] {}; ExpTest expTest = interpreter.getContext().getExpTest("truthy"); if (args.length > 1) { - expTest = interpreter.getContext().getExpTest(args[1]); + if (args[1] == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 1); + } + + expTest = interpreter.getContext().getExpTest(args[1].toString()); if (expTest == null) { - throw new InterpretException("No expression test defined with name '" + args[1] + "'", interpreter.getLineNumber()); + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.EXPRESSION_TEST, + 1, + args[1].toString() + ); + } + + if (args.length > 2) { + expArgs = Arrays.copyOfRange(args, 2, args.length); } } ForLoop loop = ObjectIterator.getLoop(var); + Variable tempVariable = new Variable( + interpreter, + String.format("%s.%s", "placeholder", attr) + ); while (loop.hasNext()) { Object val = loop.next(); - Object attrVal = interpreter.resolveProperty(val, attr); - if (expTest.evaluate(attrVal, interpreter)) { + if ( + acceptObjects == expTest.evaluate(tempVariable.resolve(val), interpreter, expArgs) + ) { result.add(val); } } return result; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SelectFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SelectFilter.java index 8c5597071..131b9b1ad 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SelectFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SelectFilter.java @@ -1,29 +1,44 @@ package com.hubspot.jinjava.lib.filter; -import java.util.ArrayList; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.lib.exptest.ExpTest; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; @JinjavaDoc( - value = "Filters a sequence of objects by applying a test to the object and only selecting the ones with the test succeeding.", - params = { - @JinjavaParam(value = "value", type = "sequence"), - @JinjavaParam(value = "exp_test", type = "name of expression test", defaultValue = "truthy", desc = "Specify which expression test to run for making the selection") - }, - snippets = { - @JinjavaSnippet( - code = "{% set some_numbers = [10, 12, 13, 3, 5, 17, 22] %}\n" + - "{% some_numbers|select('even') %}") - }) -public class SelectFilter implements Filter { + value = "Filters a sequence of objects by applying a test to the object and only selecting the ones with the test succeeding.", + input = @JinjavaParam( + value = "sequence", + type = "sequence", + desc = "Sequence to test", + required = true + ), + params = { + @JinjavaParam( + value = "exp_test", + type = "name of expression test", + defaultValue = "truthy", + desc = "Specify which expression test to run for making the selection" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set some_numbers = [10, 12, 13, 3, 5, 17, 22] %}\n" + + "{% some_numbers|select('even') %}" + ), + } +) +public class SelectFilter implements AdvancedFilter { @Override public String getName() { @@ -31,23 +46,53 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { List result = new ArrayList<>(); if (args.length == 0) { - throw new InterpretException(getName() + " requires an exp test to filter on", interpreter.getLineNumber()); + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (name of expression test to filter by)" + ); } - ExpTest expTest = interpreter.getContext().getExpTest(args[0]); + if (args[0] == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NULL, + "exp_test" + ); + } + + Object[] expArgs = new Object[] {}; + + if (args.length > 1) { + expArgs = Arrays.copyOfRange(args, 1, args.length); + } + + ExpTest expTest = interpreter.getContext().getExpTest(args[0].toString()); if (expTest == null) { - throw new InterpretException("No exp test defined for name '" + args[0] + "'", interpreter.getLineNumber()); + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.EXPRESSION_TEST, + 0, + args[0].toString() + ); } ForLoop loop = ObjectIterator.getLoop(var); while (loop.hasNext()) { Object val = loop.next(); - if (expTest.evaluate(val, interpreter)) { + if (evaluate(interpreter, expArgs, expTest, val)) { result.add(val); } } @@ -55,4 +100,12 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return result; } + boolean evaluate( + JinjavaInterpreter interpreter, + Object[] expArgs, + ExpTest expTest, + Object val + ) { + return expTest.evaluate(val, interpreter, expArgs); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ShuffleFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ShuffleFilter.java index d9c0f861b..9eaecbf88 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/ShuffleFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ShuffleFilter.java @@ -1,23 +1,32 @@ package com.hubspot.jinjava.lib.filter; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @JinjavaDoc( - value = "Randomly shuffle a given list, returning a new list with all of the items of the original list in a random order", - snippets = { - @JinjavaSnippet( - desc = "The example below is a standard blog loop that's order is randomized on page load", - code = "{% for content in contents|shuffle %}\n" + - "
Markup of each post
\n" + - "{% endfor %}") - }) + value = "Randomly shuffle a given list, returning a new list with all of the items of the original list in a random order", + input = @JinjavaParam( + value = "sequence", + type = "sequence", + desc = "Sequence to shuffle", + required = true + ), + snippets = { + @JinjavaSnippet( + desc = "The example below is a standard blog loop whose order is randomized on page load", + code = "{% for content in contents|shuffle %}\n" + + "
Markup of each post
\n" + + "{% endfor %}" + ), + } +) public class ShuffleFilter implements Filter { @Override @@ -28,13 +37,19 @@ public String getName() { @SuppressWarnings("unchecked") @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if ( + interpreter.getConfig().getRandomNumberGeneratorStrategy() == + RandomNumberGeneratorStrategy.CONSTANT_ZERO + ) { + return var; + } + if (var instanceof Collection) { - List list = new ArrayList((Collection) var); - Collections.shuffle(list); + List list = new ArrayList<>((Collection) var); + Collections.shuffle(list, interpreter.getRandom()); return list; } return var; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SliceFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SliceFilter.java index 7620826b8..2fe88baae 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SliceFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SliceFilter.java @@ -1,39 +1,63 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.math.NumberUtils; - -import com.google.common.collect.Iterators; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.lang3.math.NumberUtils; @JinjavaDoc( - value = "Slice an iterator and return a list of lists containing those items.", - params = { - @JinjavaParam(value = "value", type = "sequence", desc = "The sequence or dict that the filter is applied to"), - @JinjavaParam(value = "slices", type = "number", desc = "Specifies how many items will be sliced"), - @JinjavaParam(value = "fill_with", desc = "Used to fill missing values on the last iteration") - }, - snippets = { - @JinjavaSnippet( - desc = "create a div containing three ul tags that represent columns", - code = "{% set items = ['laptops', 'tablets', 'smartphones', 'smart watches', 'TVs'] %}\n" + - "
\n" + - " {% for column in items|slice(3) %}\n" + - "
    \n" + - " {% for item in column %}\n" + - "
  • {{ item }}
  • \n" + - " {% endfor %}\n" + - "
\n" + - " {% endfor %}\n" + - "
\n") - }) + value = "Slice an iterator and return a list of lists containing those items.", + input = @JinjavaParam( + value = "value", + type = "sequence", + desc = "The sequence or dict that the filter is applied to", + required = true + ), + params = { + @JinjavaParam( + value = "slices", + type = "number", + desc = "Specifies how many items will be sliced. Maximum value is " + + SliceFilter.MAX_SLICES + + ". ", + required = true + ), + @JinjavaParam( + value = "fillWith", + type = "object", + desc = "Specifies which object to use to fill missing values on final iteration", + required = false + ), + }, + snippets = { + @JinjavaSnippet( + desc = "create a div containing three ul tags that represent columns", + code = "{% set items = ['laptops', 'tablets', 'smartphones', 'smart watches', 'TVs'] %}\n" + + "
\n" + + " {% for column in items|slice(3) %}\n" + + "
    \n" + + " {% for item in column %}\n" + + "
  • {{ item }}
  • \n" + + " {% endfor %}\n" + + "
\n" + + " {% endfor %}\n" + + "
\n" + ), + } +) public class SliceFilter implements Filter { + public static final int MAX_SLICES = 1000; + @Override public String getName() { return "slice"; @@ -43,12 +67,69 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { ForLoop loop = ObjectIterator.getLoop(var); - if (args.length == 0) { - throw new InterpretException(getName() + " requires number of slices argument", interpreter.getLineNumber()); + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (number of slices)" + ); } int slices = NumberUtils.toInt(args[0], 3); - return Iterators.paddedPartition(loop, slices); - } + if (slices <= 0) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.POSITIVE_NUMBER, + 0, + args[0] + ); + } else if (slices > MAX_SLICES) { + interpreter.addError( + new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.OVER_LIMIT, + TemplateError.ErrorItem.FILTER, + String.format( + "The value of the 'slices' parameter is greater than %d. It's been reduced to %d", + MAX_SLICES, + MAX_SLICES + ), + null, + interpreter.getLineNumber(), + interpreter.getPosition(), + null + ) + ); + slices = MAX_SLICES; + } + + List> result = new ArrayList<>(); + List currentList = null; + int i = 0; + while (loop.hasNext()) { + if (i % slices == 0) { + if (currentList != null) { + result.add(currentList); + } + currentList = new ArrayList<>(); + } + currentList.add(loop.next()); + i++; + } + + if (currentList != null && !currentList.isEmpty()) { + result.add(currentList); + } + + if (args.length > 1 && currentList != null) { + Object fillWith = args[1]; + while (currentList.size() < slices) { + currentList.add(fillWith); + } + } + + return result; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SortFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SortFilter.java index c412cf590..688a158d6 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SortFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SortFilter.java @@ -1,41 +1,64 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -import org.apache.commons.lang3.BooleanUtils; - +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; import com.google.common.collect.Lists; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ObjectIterator; -import com.hubspot.jinjava.util.Variable; +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.commons.lang3.BooleanUtils; @JinjavaDoc( - value = "Sort an iterable.", - params = { - @JinjavaParam(value = "value", type = "iterable", desc = "The sequence or dict to sort through iteration"), - @JinjavaParam(value = "reverse", type = "boolean", defaultValue = "False", desc = "Boolean to reverse the sort order"), - @JinjavaParam(value = "case_sensitive", type = "boolean", defaultValue = "False", desc = "Determines whether or not the sorting is case sensitive"), - @JinjavaParam(value = "attribute", desc = "Specifies an attribute to sort by") - }, - snippets = { - @JinjavaSnippet( - code = "{% for item in iterable|sort %}\n" + - " ...\n" + - "{% endfor %}"), - @JinjavaSnippet( - desc = "This filter requires all parameters to sort by an attribute in HubSpot. Below is a set of posts that are retrieved and alphabetized by 'name'.", - code = "{% set my_posts = blog_recent_posts('default', limit=5) %}\n" + - "{% for item in my_posts|sort(False, False,'name') %}\n" + - " {{ item.name }}
\n" + - "{% endfor %}") - }) + value = "Sort an iterable.", + input = @JinjavaParam( + value = "value", + type = "iterable", + desc = "The sequence or dict to sort through iteration", + required = true + ), + params = { + @JinjavaParam( + value = "reverse", + type = "boolean", + defaultValue = "False", + desc = "Boolean to reverse the sort order" + ), + @JinjavaParam( + value = "case_sensitive", + type = "boolean", + defaultValue = "False", + desc = "Determines whether or not the sorting is case sensitive" + ), + @JinjavaParam(value = "attribute", desc = "Specifies an attribute to sort by"), + }, + snippets = { + @JinjavaSnippet( + code = "{% for item in iterable|sort %}\n" + " ...\n" + "{% endfor %}" + ), + @JinjavaSnippet( + desc = "This filter requires all parameters to sort by an attribute in HubSpot. Below is a set of posts that are retrieved and alphabetized by 'name'.", + code = "{% set my_posts = blog_recent_posts('default', limit=5) %}\n" + + "{% for item in my_posts|sort(False, False,'name') %}\n" + + " {{ item.name }}
\n" + + "{% endfor %}" + ), + } +) public class SortFilter implements Filter { + private static final Splitter DOT_SPLITTER = Splitter.on('.').omitEmptyStrings(); + private static final Joiner DOT_JOINER = Joiner.on('.'); + @Override public String getName() { return "sort"; @@ -44,45 +67,65 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { if (var == null) { - return var; + return null; } - boolean reverse = false; - if (args.length > 0) { - reverse = BooleanUtils.toBoolean(args[0]); - } + boolean reverse = args.length > 0 && BooleanUtils.toBoolean(args[0]); + boolean caseSensitive = args.length > 1 && BooleanUtils.toBoolean(args[1]); - boolean caseSensitive = false; - if (args.length > 1) { - caseSensitive = BooleanUtils.toBoolean(args[1]); + if (args.length > 2 && args[2] == null) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.NULL, 2); } - String attr = null; - if (args.length > 2) { - attr = args[2]; + List attr = args.length > 2 + ? DOT_SPLITTER.splitToList(args[2]) + : Collections.emptyList(); + return Lists + .newArrayList(ObjectIterator.getLoop(var)) + .stream() + .sorted( + Comparator.comparing( + o -> mapObject(interpreter, o, attr), + new ObjectComparator(reverse, caseSensitive) + ) + ) + .collect(Collectors.toList()); + } + + private Object mapObject( + JinjavaInterpreter interpreter, + Object o, + List propertyChain + ) { + if (o == null) { + throw new InvalidInputException(interpreter, this, InvalidReason.NULL_IN_LIST); } - List result = Lists.newArrayList(ObjectIterator.getLoop(var)); - Collections.sort(result, new ObjectComparator(interpreter, reverse, caseSensitive, attr)); + if (propertyChain.isEmpty()) { + return o; + } + Object result = interpreter.resolveProperty(o, propertyChain); + if (result == null) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.NULL_ATTRIBUTE_IN_LIST, + 2, + DOT_JOINER.join(propertyChain) + ); + } return result; } - private static class ObjectComparator implements Comparator { + private static class ObjectComparator implements Comparator, Serializable { + private final boolean reverse; private final boolean caseSensitive; - private final Variable variable; - public ObjectComparator(JinjavaInterpreter interpreter, boolean reverse, boolean caseSensitive, String attr) { + ObjectComparator(boolean reverse, boolean caseSensitive) { this.reverse = reverse; this.caseSensitive = caseSensitive; - - if (attr != null) { - this.variable = new Variable(interpreter, "o." + attr); - } - else { - this.variable = null; - } } @SuppressWarnings("unchecked") @@ -90,15 +133,12 @@ public ObjectComparator(JinjavaInterpreter interpreter, boolean reverse, boolean public int compare(Object o1, Object o2) { int result = 0; - if (variable != null) { - o1 = variable.resolve(o1); - o2 = variable.resolve(o2); - } - if (o1 instanceof String && !caseSensitive) { result = ((String) o1).compareToIgnoreCase((String) o2); - } - else if (Comparable.class.isAssignableFrom(o1.getClass()) && Comparable.class.isAssignableFrom(o2.getClass())) { + } else if ( + Comparable.class.isAssignableFrom(o1.getClass()) && + Comparable.class.isAssignableFrom(o2.getClass()) + ) { result = ((Comparable) o1).compareTo(o2); } @@ -108,6 +148,5 @@ else if (Comparable.class.isAssignableFrom(o1.getClass()) && Comparable.class.is return result; } - } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SplitFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SplitFilter.java index 185ed2163..064e2a3b9 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SplitFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SplitFilter.java @@ -1,9 +1,5 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.math.NumberUtils; - import com.google.common.base.CharMatcher; import com.google.common.base.Splitter; import com.google.common.collect.Lists; @@ -11,6 +7,8 @@ import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.math.NumberUtils; /** * split(separator=' ', limit=0) @@ -22,22 +20,33 @@ * @author jstehler */ @JinjavaDoc( - value = "Splits the input string into a list on the given separator", - params = { - @JinjavaParam(value = "s", desc = "The string to split"), - @JinjavaParam(value = "separator", defaultValue = " ", desc = "Specifies the separator to split the variable by"), - @JinjavaParam(value = "limit", type = "number", defaultValue = "0", desc = "Limits resulting list by putting remainder of string into last list item") - }, - snippets = { - @JinjavaSnippet( - code = "{% set string_to_split = \"Stephen; David; Cait; Nancy; Mike; Joe; Niall; Tim; Amanda\" %}\n" + - "{% set names = string_to_split|split(';', 4) %}\n" + - "
    \n" + - " {% for name in names %}\n" + - "
  • {{ name }}
  • \n" + - " {% endfor %}\n" + - "
") - }) + value = "Splits the input string into a list on the given separator", + input = @JinjavaParam(value = "string", desc = "The string to split", required = true), + params = { + @JinjavaParam( + value = "separator", + defaultValue = " ", + desc = "Specifies the separator to split the variable by" + ), + @JinjavaParam( + value = "limit", + type = "number", + defaultValue = "0", + desc = "Limits resulting list by putting remainder of string into last list item" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set string_to_split = \"Stephen; David; Cait; Nancy; Mike; Joe; Niall; Tim; Amanda\" %}\n" + + "{% set names = string_to_split|split(';', 4) %}\n" + + "
    \n" + + " {% for name in names %}\n" + + "
  • {{ name }}
  • \n" + + " {% endfor %}\n" + + "
" + ), + } +) public class SplitFilter implements Filter { @Override @@ -49,21 +58,25 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { Splitter splitter; - if (args.length > 0) { - splitter = Splitter.on(args[0]); - } - else { - splitter = Splitter.on(CharMatcher.WHITESPACE); + if (args != null && args.length > 0) { + if (args[0] != null) { + splitter = Splitter.on(args[0]); + } else { + splitter = Splitter.on(CharMatcher.whitespace()); + } + } else { + splitter = Splitter.on(CharMatcher.whitespace()); } - if (args.length > 1) { + if (args != null && args.length > 1) { int limit = NumberUtils.toInt(args[1], 0); if (limit > 0) { splitter = splitter.limit(limit); } } - return Lists.newArrayList(splitter.omitEmptyStrings().trimResults().split(Objects.toString(var, ""))); + return Lists.newArrayList( + splitter.omitEmptyStrings().trimResults().split(Objects.toString(var, "")) + ); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/StringFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/StringFilter.java index f2f7828fe..622800f5f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/StringFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/StringFilter.java @@ -1,18 +1,24 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; @JinjavaDoc( - value = "Returns string value of object", - snippets = { - @JinjavaSnippet( - code = "{% set number_to_string = 45 %}\n" + - "{{ number_to_string|string }}") - }) + value = "Returns string value of object", + input = @JinjavaParam( + value = "value", + desc = "The value to turn into a string", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set number_to_string = 45 %}\n" + "{{ number_to_string|string }}" + ), + } +) public class StringFilter implements Filter { @Override @@ -24,5 +30,4 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { return Objects.toString(var); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/StringToDateFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/StringToDateFilter.java new file mode 100644 index 000000000..1c5b7754b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/StringToDateFilter.java @@ -0,0 +1,49 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.fn.Functions; + +@JinjavaDoc( + value = "Converts a date string and date format to a date object", + input = @JinjavaParam(value = "dateString", desc = "Date string", required = true), + params = { + @JinjavaParam( + value = "dateFormat", + desc = "Format of the date string", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{{ '3/3/21'|strtodate('M/d/yy') }}") } +) +public class StringToDateFilter implements Filter { + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (date format string)" + ); + } + + if (var == null) { + return null; + } + + if (!(var instanceof String)) { + var = String.valueOf(var); + } + + return Functions.stringToDate((String) var, args[0]); + } + + @Override + public String getName() { + return "strtodate"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/StringToTimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/StringToTimeFilter.java new file mode 100644 index 000000000..b778cd33e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/StringToTimeFilter.java @@ -0,0 +1,55 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.fn.Functions; + +@JinjavaDoc( + value = "Converts a datetime string and datetime format to a datetime object", + input = @JinjavaParam( + value = "datetimeString", + desc = "Datetime string", + required = true + ), + params = { + @JinjavaParam( + value = "datetimeFormat", + desc = "Format of the datetime string", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{% mydatetime|unixtimestamp %}") } +) +public class StringToTimeFilter implements Filter { + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (datetime format string)" + ); + } + + if (var == null) { + return null; + } + + if (!(var instanceof String)) { + throw new InvalidInputException(interpreter, this, InvalidReason.STRING); + } + + return Functions.stringToTime((String) var, args[0]); + } + + @Override + public String getName() { + return "strtotime"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/StripTagsFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/StripTagsFilter.java index dfe9f2543..9d8db5a19 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/StripTagsFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/StripTagsFilter.java @@ -1,23 +1,31 @@ package com.hubspot.jinjava.lib.filter; -import java.util.regex.Pattern; - -import org.jsoup.Jsoup; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.regex.Pattern; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; /** * striptags(value) Strip SGML/XML tags and replace adjacent whitespace by one space. */ @JinjavaDoc( - value = "Strip SGML/XML tags and replace adjacent whitespace by one space.", - snippets = { - @JinjavaSnippet( - code = "{% set some_html = \"
Some text
\" %}\n" + - "{{ some_html|striptags }}") - }) + value = "Strip SGML/XML tags and replace adjacent whitespace by one space.", + input = @JinjavaParam( + value = "string", + desc = "string to strip tags from", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set some_html = \"
Some text
\" %}\n" + + "{{ some_html|striptags }}" + ), + } +) public class StripTagsFilter implements Filter { private static final Pattern WHITESPACE = Pattern.compile("\\s{2,}"); @@ -27,17 +35,28 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar if (!(object instanceof String)) { return object; } + int numDeferredTokensStart = interpreter.getContext().getDeferredTokens().size(); + + try (JinjavaInterpreter.InterpreterScopeClosable c = interpreter.enterScope()) { + String val = interpreter.renderFlat((String) object); + if (interpreter.getContext().getDeferredTokens().size() > numDeferredTokensStart) { + throw new DeferredValueException("Deferred in StripTagsFilter"); + } + + String cleanedVal = Jsoup.parse(val).text(); + cleanedVal = Jsoup.clean(cleanedVal, Safelist.none()); - String val = interpreter.renderString((String) object); - String strippedVal = Jsoup.parseBodyFragment(val).text(); - String normalizedVal = WHITESPACE.matcher(strippedVal).replaceAll(" "); + // backwards compatibility with Jsoup.parse + cleanedVal = cleanedVal.replaceAll(" ", " "); - return normalizedVal; + String normalizedVal = WHITESPACE.matcher(cleanedVal).replaceAll(" "); + + return normalizedVal; + } } @Override public String getName() { return "striptags"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SumFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SumFilter.java index 7190dd1c2..47962a2d1 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/SumFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SumFilter.java @@ -1,31 +1,46 @@ package com.hubspot.jinjava.lib.filter; -import java.math.BigDecimal; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.math.BigDecimal; +import java.util.Map; +import java.util.Objects; @JinjavaDoc( - value = "Returns the sum of a sequence of numbers plus the value of parameter ‘start’ (which defaults to 0). When the sequence is empty it returns start.", - params = { - @JinjavaParam(value = "value", type = "iterable", desc = "Selects the sequence or dict to sum values from"), - @JinjavaParam(value = "attribute", desc = "Specify an optional attribute of dict to sum"), - @JinjavaParam(value = "start", type = "number", defaultValue = "0", desc = "Sets a value to return, if there is nothing in the variable to sum") - }, - snippets = { - @JinjavaSnippet( - code = "{% set sum_this = [1, 2, 3, 4, 5] %}\n" + - "{{ sum_this|sum }}\n"), - @JinjavaSnippet( - desc = "Sum up only certain attributes", - code = "Total: {{ items|sum(attribute='price') }}") - }) -public class SumFilter implements Filter { + value = "Returns the sum of a sequence of numbers plus the value of parameter ‘start’ (which defaults to 0). When the sequence is empty it returns start.", + input = @JinjavaParam( + value = "value", + type = "iterable", + desc = "Selects the sequence or dict to sum values from", + required = true + ), + params = { + @JinjavaParam( + value = "start", + type = "number", + defaultValue = "0", + desc = "Sets a value to return, if there is nothing in the variable to sum" + ), + @JinjavaParam( + value = "attribute", + desc = "Specify an optional attribute of dict to sum" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set sum_this = [1, 2, 3, 4, 5] %}\n" + "{{ sum_this|sum }}\n" + ), + @JinjavaSnippet( + desc = "Sum up only certain attributes", + code = "Total: {{ items|sum(attribute='price') }}" + ), + } +) +public class SumFilter implements AdvancedFilter { @Override public String getName() { @@ -33,20 +48,23 @@ public String getName() { } @Override - public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { ForLoop loop = ObjectIterator.getLoop(var); BigDecimal sum = BigDecimal.ZERO; - String attr = null; + String attr = kwargs.containsKey("attribute") + ? kwargs.get("attribute").toString() + : null; if (args.length > 0) { - attr = args[0]; - } - if (args.length > 1) { try { - sum = sum.add(new BigDecimal(args[1])); - } catch (NumberFormatException e) { - } + sum = sum.add(new BigDecimal(args[0].toString())); + } catch (NumberFormatException ignored) {} } while (loop.hasNext()) { @@ -59,22 +77,22 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) if (attr != null) { val = interpreter.resolveProperty(val, attr); + if (val == null) { + continue; + } } try { if (Number.class.isAssignableFrom(val.getClass())) { addend = new BigDecimal(((Number) val).doubleValue()); - } - else { + } else { addend = new BigDecimal(Objects.toString(val, "0")); } - } catch (NumberFormatException e) { - } + } catch (NumberFormatException e) {} sum = sum.add(addend); } return sum; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilter.java new file mode 100644 index 000000000..a329ede86 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilter.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.collect.Sets; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import java.util.ArrayList; +import java.util.Set; + +@JinjavaDoc( + value = "Returns a list containing elements present in only one list.", + input = @JinjavaParam( + value = "value", + type = "sequence", + desc = "The first list", + required = true + ), + params = { + @JinjavaParam( + value = "list", + type = "sequence", + desc = "The second list", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3]|symmetric_difference([2, 3, 4]) }}") } +) +public class SymmetricDifferenceFilter extends AbstractSetFilter { + + @Override + public Object filter(Set varSet, Set argSet) { + return new ArrayList<>(Sets.symmetricDifference(varSet, argSet)); + } + + @Override + public String getName() { + return "symmetric_difference"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TitleFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TitleFilter.java index 2b01d04b9..4bc07c02e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TitleFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TitleFilter.java @@ -1,8 +1,7 @@ package com.hubspot.jinjava.lib.filter; -import org.apache.commons.lang3.text.WordUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @@ -12,12 +11,15 @@ * @author jstehler */ @JinjavaDoc( - value = "Return a titlecased version of the value. I.e. words will start with uppercase letters, all remaining characters are lowercase.", - snippets = { - @JinjavaSnippet( - code = "{{ \"My title should be titlecase\"|title }} " - ) - }) + value = "Return a titlecased version of the value. I.e. words will start with uppercase letters, all remaining characters are lowercase.", + input = @JinjavaParam( + value = "string", + type = "string", + desc = "the string to titlecase", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ \"My title should be titlecase\"|title }} ") } +) public class TitleFilter implements Filter { @Override @@ -27,11 +29,36 @@ public String getName() { @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { - if (var instanceof String) { - String value = (String) var; - return WordUtils.capitalize(value); + if (var == null) { + return null; + } + + String value = var.toString(); + + char[] chars = value.toCharArray(); + boolean titleCased = false; + + for (int i = 0; i < chars.length; i++) { + if (Character.isWhitespace(chars[i])) { + titleCased = false; + continue; + } + + char original = chars[i]; + if (titleCased) { + chars[i] = Character.toLowerCase(original); + } else { + if (charCanBeTitlecased(original)) { + chars[i] = Character.toTitleCase(original); + titleCased = true; + } + } } - return var; + + return new String(chars); } + private boolean charCanBeTitlecased(char c) { + return Character.toLowerCase(c) != Character.toTitleCase(c); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ToJsonFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ToJsonFilter.java new file mode 100644 index 000000000..9b6d45512 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ToJsonFilter.java @@ -0,0 +1,54 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.serialization.LengthLimitingWriter; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.concurrent.atomic.AtomicInteger; + +@JinjavaDoc( + value = "Writes object as a JSON string", + input = @JinjavaParam( + value = "object", + desc = "Object to write to JSON", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{object|tojson}}") } +) +public class ToJsonFilter implements Filter { + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + try { + if (interpreter.getConfig().getMaxOutputSize() > 0) { + AtomicInteger remainingLength = new AtomicInteger( + (int) Math.min(Integer.MAX_VALUE, interpreter.getConfig().getMaxOutputSize()) + ); + Writer writer = new LengthLimitingWriter(new CharArrayWriter(), remainingLength); + interpreter.getConfig().getObjectMapper().writeValue(writer, var); + return writer.toString(); + } else { + return interpreter.getConfig().getObjectMapper().writeValueAsString(var); + } + } catch (IOException e) { + if (e.getCause() instanceof DeferredValueException) { + throw (DeferredValueException) e.getCause(); + } + PyishObjectMapper.handleLengthLimitingException(e); + throw new InvalidInputException(interpreter, this, InvalidReason.JSON_WRITE); + } + } + + @Override + public String getName() { + return "tojson"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/ToYamlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/ToYamlFilter.java new file mode 100644 index 000000000..a78d02876 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/ToYamlFilter.java @@ -0,0 +1,40 @@ +package com.hubspot.jinjava.lib.filter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +@JinjavaDoc( + value = "Writes object as a YAML string", + input = @JinjavaParam( + value = "object", + desc = "Object to write to YAML", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{object|toyaml}}") } +) +public class ToYamlFilter implements Filter { + + private static final YAMLMapper OBJECT_MAPPER = new YAMLMapper() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER); + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + try { + return OBJECT_MAPPER.writeValueAsString(var); + } catch (JsonProcessingException e) { + throw new InvalidInputException(interpreter, this, InvalidReason.JSON_WRITE); + } + } + + @Override + public String getName() { + return "toyaml"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TrimFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TrimFilter.java index b32335997..f06145e46 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TrimFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TrimFilter.java @@ -1,22 +1,25 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; /** * trim(value) Strip leading and trailing whitespace. */ @JinjavaDoc( - value = "Strip leading and trailing whitespace.", - snippets = { - @JinjavaSnippet( - code = "{{ \" remove whitespace \"|trim }}") - }) + value = "Strip leading and trailing whitespace.", + input = @JinjavaParam( + value = "string", + type = "string", + desc = "the string to strip whitespace from", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ \" remove whitespace \"|trim }}") } +) public class TrimFilter implements Filter { @Override @@ -28,5 +31,4 @@ public String getName() { public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { return StringUtils.trim(Objects.toString(var)); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateFilter.java index 18f757dc3..1309ccf5d 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateFilter.java @@ -22,34 +22,54 @@ import com.hubspot.jinjava.lib.fn.Functions; @JinjavaDoc( - value = "Return a truncated copy of the string. The length is specified with the first parameter which defaults to 255. " + - "If the second parameter is true the filter will cut the text at length. Otherwise it will discard the last word. " + - "If the text was in fact truncated it will append an ellipsis sign (\"...\"). If you want a different ellipsis sign " + - "than \"...\" you can specify it using the third parameter.", - params = { - @JinjavaParam(value = "s", desc = "The string to truncate"), - @JinjavaParam(value = "length", type = "number", defaultValue = "255", desc = "Specifies the length at which to truncate the text (includes HTML characters)"), - @JinjavaParam(value = "killwords", type = "boolean", defaultValue = "False", desc = "If true, the string will cut text at length"), - @JinjavaParam(value = "end", defaultValue = "...", desc = "The characters that will be added to indicate where the text was truncated") - }, - snippets = { - @JinjavaSnippet( - code = "{{ \"I only want to show the first sentence. Not the second.\"|truncate(40) }} ", - output = "I only want to show the first sentence."), - @JinjavaSnippet( - code = "{{ \"I only want to show the first sentence. Not the second.\"|truncate(35, True, '..') }}", - output = "I only want to show the first sente.."), - }) + value = "Return a truncated copy of the string. The length is specified with the first parameter which defaults to 255. " + + "If the second parameter is true the filter will cut the text at length. Otherwise it will discard the last word. " + + "If the text was in fact truncated it will append an ellipsis sign (\"...\"). If you want a different ellipsis sign " + + "than \"...\" you can specify it using the third parameter.", + input = @JinjavaParam( + value = "string", + desc = "The string to truncate", + required = true + ), + params = { + @JinjavaParam( + value = "length", + type = "number", + defaultValue = "255", + desc = "Specifies the length at which to truncate the text (includes HTML characters)" + ), + @JinjavaParam( + value = "killwords", + type = "boolean", + defaultValue = "False", + desc = "If true, the string will cut text at length" + ), + @JinjavaParam( + value = "end", + defaultValue = "...", + desc = "The characters that will be added to indicate where the text was truncated" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{{ \"I only want to show the first sentence. Not the second.\"|truncate(40) }} ", + output = "I only want to show the first sentence." + ), + @JinjavaSnippet( + code = "{{ \"I only want to show the first sentence. Not the second.\"|truncate(35, True, '..') }}", + output = "I only want to show the first sente.." + ), + } +) public class TruncateFilter implements Filter { @Override public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { - return Functions.truncate(object, (Object[]) arg); + return Functions.truncate(object, arg); } @Override public String getName() { return "truncate"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java index 5275f1bda..defef913e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java @@ -1,9 +1,15 @@ package com.hubspot.jinjava.lib.filter; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.SafeString; +import java.util.Map; import java.util.Objects; - import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; @@ -13,29 +19,93 @@ import org.jsoup.nodes.TextNode; import org.jsoup.select.NodeVisitor; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaParam; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.fn.Functions; +@JinjavaDoc( + value = "Truncates a given string, respecting html markup (i.e. will properly close all nested tags)", + input = @JinjavaParam(value = "html", desc = "HTML to truncate", required = true), + params = { + @JinjavaParam( + value = "length", + type = "number", + defaultValue = "255", + desc = "Length at which to truncate text (HTML characters not included)" + ), + @JinjavaParam( + value = "end", + defaultValue = "...", + desc = "The characters that will be added to indicate where the text was truncated" + ), + @JinjavaParam( + value = "breakword", + type = "boolean", + defaultValue = "false", + desc = "If set to true, text will be truncated in the middle of words" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{{ \"

I want to truncate this text without breaking my HTML

\"|truncatehtml(28, '..', false) }}", + output = "

I want to truncate this text without breaking my HTML

" + ), + } +) +public class TruncateHtmlFilter implements AdvancedFilter { -@JinjavaDoc(value = "Truncates a given string, respecting html markup (i.e. will properly close all nested tags)", params = { - @JinjavaParam(value = "html", desc = "HTML to truncate"), - @JinjavaParam(value = "length", type = "number", defaultValue = "255", desc = "Length at which to truncate text (HTML characters not included)"), - @JinjavaParam(value = "end", defaultValue = "...", desc = "The characters that will be added to indicate where the text was truncated"), - @JinjavaParam(value = "breakword", type = "boolean", defaultValue = "false", desc = "If set to true, text will be truncated in the middle of words") -}, snippets = { - @JinjavaSnippet(code = "{{ \"

I want to truncate this text without breaking my HTML

\"|truncatehtml(28, '..', false) }}", output = "

I want to truncate this text without breaking my HTML

") -}) -public class TruncateHtmlFilter implements Filter { private static final int DEFAULT_TRUNCATE_LENGTH = 255; private static final String DEFAULT_END = "..."; + private static final String LENGTH_KEY = "length"; + private static final String END_KEY = "end"; + private static final String BREAKWORD_KEY = "breakword"; @Override public String getName() { return "truncatehtml"; } + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + String length = null; + if (kwargs.containsKey(LENGTH_KEY)) { + length = Objects.toString(kwargs.get(LENGTH_KEY)); + } + String end = null; + if (kwargs.containsKey(END_KEY)) { + end = Objects.toString(kwargs.get(END_KEY)); + } + String breakword = null; + if (kwargs.containsKey(BREAKWORD_KEY)) { + breakword = Objects.toString(kwargs.get(BREAKWORD_KEY)); + } + + String[] newArgs = new String[3]; + for (int i = 0; i < args.length; i++) { + if (i >= newArgs.length) { + break; + } + newArgs[i] = Objects.toString(args[i]); + } + + if (length != null) { + newArgs[0] = length; + } + if (end != null) { + newArgs[1] = end; + } + if (breakword != null) { + newArgs[2] = breakword; + } + + if (var instanceof SafeString) { + return filter((SafeString) var, interpreter, newArgs); + } + + return filter(var, interpreter, newArgs); + } + @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { if (var instanceof String) { @@ -46,21 +116,37 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) try { length = Integer.parseInt(Objects.toString(args[0])); } catch (Exception e) { - ENGINE_LOG.warn("truncatehtml(): error setting length for {}, using default {}", args[0], DEFAULT_TRUNCATE_LENGTH); + interpreter.addError( + TemplateError.fromInvalidArgumentException( + new InvalidArgumentException( + interpreter, + "truncatehtml", + String.format( + "truncatehtml(): error setting length of %s, using default of %d", + args[0], + DEFAULT_TRUNCATE_LENGTH + ) + ) + ) + ); } } - if (args.length > 1) { - ends = Objects.toString(args[1]); + if (args.length > 1 && args[1] != null) { + ends = args[1]; } boolean killwords = false; - if (args.length > 2) { + if (args.length > 2 && args[2] != null) { killwords = BooleanUtils.toBoolean(args[2]); } Document dom = Jsoup.parseBodyFragment((String) var); - ContentTruncatingNodeVisitor visitor = new ContentTruncatingNodeVisitor(length, ends, killwords); + ContentTruncatingNodeVisitor visitor = new ContentTruncatingNodeVisitor( + length, + ends, + killwords + ); dom.select("body").traverse(visitor); dom.select(".__deleteme").remove(); @@ -71,12 +157,13 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) } private static class ContentTruncatingNodeVisitor implements NodeVisitor { - private int maxTextLen; + + private final int maxTextLen; private int textLen; - private String ending; - private boolean killwords; + private final String ending; + private final boolean killwords; - public ContentTruncatingNodeVisitor(int maxTextLen, String ending, boolean killwords) { + ContentTruncatingNodeVisitor(int maxTextLen, String ending, boolean killwords) { this.maxTextLen = maxTextLen; this.ending = ending; this.killwords = killwords; diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilter.java new file mode 100644 index 000000000..fa7919469 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilter.java @@ -0,0 +1,46 @@ +/********************************************************************** + * Copyright (c) 2022 HubSpot 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.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.StringEscapeUtils; + +@JinjavaDoc( + value = "Converts HTML entities in string s to Unicode characters.", + input = @JinjavaParam(value = "s", desc = "String to unescape", required = true), + snippets = { + @JinjavaSnippet( + code = "{% set escaped_string = \"
This & that
\" %}\n" + + "{{ escaped_string|unescape_html }}" + ), + } +) +public class UnescapeHtmlFilter implements Filter { + + @Override + public Object filter(Object object, JinjavaInterpreter interpreter, String... arg) { + return StringEscapeUtils.unescapeHtml4(Objects.toString(object, "")); + } + + @Override + public String getName() { + return "unescape_html"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UnionFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UnionFilter.java new file mode 100644 index 000000000..eb74124ef --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UnionFilter.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.filter; + +import com.google.common.collect.Sets; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import java.util.ArrayList; +import java.util.Set; + +@JinjavaDoc( + value = "Returns a list containing elements present in either list", + input = @JinjavaParam( + value = "value", + type = "sequence", + desc = "The first list", + required = true + ), + params = { + @JinjavaParam( + value = "list", + type = "sequence", + desc = "The second list", + required = true + ), + }, + snippets = { @JinjavaSnippet(code = "{{ [1, 2, 3]|union([2, 3, 4]) }}") } +) +public class UnionFilter extends AbstractSetFilter { + + @Override + public Object filter(Set varSet, Set argSet) { + return new ArrayList<>(Sets.union(varSet, argSet)); + } + + @Override + public String getName() { + return "union"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UniqueFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UniqueFilter.java index caf72f9fa..908ece3dc 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/UniqueFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UniqueFilter.java @@ -1,31 +1,40 @@ package com.hubspot.jinjava.lib.filter; -import java.util.LinkedHashMap; -import java.util.Map; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.util.LinkedHashMap; +import java.util.Map; @JinjavaDoc( - value = "Extract a unique set from a sequence of objects", - params = { - @JinjavaParam(value = "sequence", type = "sequence", desc = "Sequence to filter"), - @JinjavaParam(value = "attr", type = "Optional attribute on object to use as unique identifier") - }, - snippets = { - @JinjavaSnippet( - desc = "Filter duplicated strings from a sequence of strings", - code = "{{ ['foo', 'bar', 'foo', 'other'] | unique | join(', ') }}", - output = "foo, bar, other"), - @JinjavaSnippet( - desc = "Filter out duplicate blog posts", - code = "{% for content in contents|unique(attr='slug') %}\n" - + "\n{% endfor %}") - }) + value = "Extract a unique set from a sequence of objects", + input = @JinjavaParam( + value = "sequence", + type = "sequence", + desc = "Sequence to filter", + required = true + ), + params = { + @JinjavaParam( + value = "attr", + type = "Optional attribute on object to use as unique identifier" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Filter duplicated strings from a sequence of strings", + code = "{{ ['foo', 'bar', 'foo', 'other'] | unique | join(', ') }}", + output = "foo, bar, other" + ), + @JinjavaSnippet( + desc = "Filter out duplicate blog posts", + code = "{% for content in contents|unique(attr='slug') %}\n" + "\n{% endfor %}" + ), + } +) public class UniqueFilter implements Filter { @Override @@ -58,5 +67,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return result.values(); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilter.java new file mode 100644 index 000000000..e1fe8f298 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilter.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureActivationStrategy; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.lib.fn.Functions; + +@JinjavaDoc( + value = "Gets the UNIX timestamp value (in milliseconds) of a date object", + input = @JinjavaParam(value = "value", desc = "The date variable", required = true), + snippets = { @JinjavaSnippet(code = "{% mydatetime|unixtimestamp %}") } +) +public class UnixTimestampFilter implements Filter { + + @Override + public String getName() { + return "unixtimestamp"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + interpreter.addError( + TemplateError.fromMissingFilterArgException( + new InvalidArgumentException( + interpreter, + "unixtimestamp", + "unixtimestamp filter called with null datetime" + ) + ) + ); + + FeatureActivationStrategy feat = interpreter + .getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.FIXED_DATE_TIME_FILTER_NULL_ARG); + + if (feat.isActive(interpreter.getContext())) { + var = ((DateTimeFeatureActivationStrategy) feat).getActivateAt(); + } + } + + return Functions.unixtimestamp(var); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UpperFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UpperFilter.java index 53df759d6..da047c237 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/UpperFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UpperFilter.java @@ -16,14 +16,20 @@ package com.hubspot.jinjava.lib.filter; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @JinjavaDoc( - value = "Convert a value to uppercase", - snippets = { - @JinjavaSnippet(code = "{{ \"text to make uppercase\"|uppercase }}") - }) + value = "Convert a value to uppercase", + input = @JinjavaParam( + value = "string", + type = "string", + desc = "the string to uppercase", + required = true + ), + snippets = { @JinjavaSnippet(code = "{{ \"text to make uppercase\"|uppercase }}") } +) public class UpperFilter implements Filter { @Override @@ -39,5 +45,4 @@ public Object filter(Object object, JinjavaInterpreter interpreter, String... ar public String getName() { return "upper"; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilter.java new file mode 100644 index 000000000..af1f279d5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilter.java @@ -0,0 +1,75 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; + +@JinjavaDoc( + value = "Decodes encoded URL strings back to the original URL. It accepts both dictionaries and regular strings as well as pairwise iterables.", + input = @JinjavaParam( + value = "url", + type = "string", + desc = "the url to decode", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{{ \"http%3A%2F%2Ffoo.com%3Fbar%26food\"|urldecode }}", + output = "http://foo.com?bar&food" + ), + } +) +public class UrlDecodeFilter implements Filter { + + @Override + public String getName() { + return "urldecode"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null && args.length == 0) { + return ""; + } + + if (var != null) { + if (Map.class.isAssignableFrom(var.getClass())) { + @SuppressWarnings("unchecked") + Map dict = (Map) var; + + List paramPairs = new ArrayList<>(); + + for (Map.Entry param : dict.entrySet()) { + StringBuilder paramPair = new StringBuilder(); + paramPair.append(urlDecode(Objects.toString(param.getKey()))); + paramPair.append("="); + paramPair.append(urlDecode(Objects.toString(param.getValue()))); + + paramPairs.add(paramPair.toString()); + } + + return StringUtils.join(paramPairs, "&"); + } + + return urlDecode(var.toString()); + } + + return urlDecode(args[0]); + } + + private String urlDecode(String s) { + try { + return URLDecoder.decode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilter.java index 457fffeb3..e4ce1a1bc 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilter.java @@ -1,24 +1,29 @@ package com.hubspot.jinjava.lib.filter; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; - import org.apache.commons.lang3.StringUtils; -import com.google.common.base.Throwables; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @JinjavaDoc( - value = "Escape strings for use in URLs (uses UTF-8 encoding). It accepts both dictionaries and regular strings as well as pairwise iterables.", - snippets = { - @JinjavaSnippet(code = "{{ \"Escape & URL encode this string\"|urlencode }}") - }) + value = "Escape strings for use in URLs (uses UTF-8 encoding). It accepts both dictionaries and regular strings as well as pairwise iterables.", + input = @JinjavaParam( + value = "url", + type = "string", + desc = "the url to escape", + required = true + ), + snippets = { + @JinjavaSnippet(code = "{{ \"Escape & URL encode this string\"|urlencode }}"), + } +) public class UrlEncodeFilter implements Filter { @Override @@ -61,8 +66,7 @@ private String urlEncode(String s) { try { return URLEncoder.encode(s, "UTF-8"); } catch (UnsupportedEncodingException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/UrlizeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/UrlizeFilter.java index 5686a8393..fa2a339ac 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/UrlizeFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/UrlizeFilter.java @@ -1,34 +1,49 @@ package com.hubspot.jinjava.lib.filter; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; - import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaParam; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @JinjavaDoc( - value = "Converts URLs in plain text into clickable links.", - params = { - @JinjavaParam(value = "value", desc = "string URL to convert to an anchor"), - @JinjavaParam(value = "trim_url_limit", type = "number", desc = "Sets a character limit"), - @JinjavaParam(value = "nofollow", type = "boolean", defaultValue = "False", desc = "Adds nofollow to generated link tag"), - @JinjavaParam(value = "target", desc = "Adds target attr to generated link tag") - }, - snippets = { - @JinjavaSnippet( - desc = "links are shortened to 40 chars and defined with rel=\"nofollow\"", - code = "{{ \"http://www.hubspot.com\"|urlize(40) }}"), - @JinjavaSnippet( - desc = "If target is specified, the target attribute will be added to the tag", - code = "{{ \"http://www.hubspot.com\"|urlize(10, true, target='_blank') }}"), - }) + value = "Converts URLs in plain text into clickable links.", + input = @JinjavaParam( + value = "value", + type = "string", + desc = "string URL to convert to an anchor", + required = true + ), + params = { + @JinjavaParam( + value = "trim_url_limit", + type = "number", + desc = "Sets a character limit" + ), + @JinjavaParam( + value = "nofollow", + type = "boolean", + defaultValue = "False", + desc = "Adds nofollow to generated link tag" + ), + @JinjavaParam(value = "target", desc = "Adds target attr to generated link tag"), + }, + snippets = { + @JinjavaSnippet( + desc = "links are shortened to 40 chars and defined with rel=\"nofollow\"", + code = "{{ \"http://www.hubspot.com\"|urlize(40) }}" + ), + @JinjavaSnippet( + desc = "If target is specified, the target attribute will be added to the tag", + code = "{{ \"http://www.hubspot.com\"|urlize(10, true, target='_blank') }}" + ), + } +) public class UrlizeFilter implements Filter { @Override @@ -80,7 +95,7 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) } private static final Pattern URL_RE = Pattern.compile( - "(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]", - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); - + "(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE + ); } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/WordCountFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/WordCountFilter.java index 35a725458..9c504185f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/WordCountFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/WordCountFilter.java @@ -1,22 +1,28 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @JinjavaDoc( - value = "Counts the words in the given string", - snippets = { - @JinjavaSnippet( - code = "{% set count_words = \"Count the number of words in this variable\" %}\n" + - "{{ count_words|wordcount }}" - ) - - }) + value = "Counts the words in the given string", + input = @JinjavaParam( + value = "string", + type = "string", + desc = "string to count the words from", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% set count_words = \"Count the number of words in this variable\" %}\n" + + "{{ count_words|wordcount }}" + ), + } +) public class WordCountFilter implements Filter { @Override @@ -34,9 +40,11 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) count++; } - return count; + return Integer.valueOf(count); } - private static final Pattern WORD_RE = Pattern.compile("\\w+", Pattern.UNICODE_CHARACTER_CLASS | Pattern.MULTILINE); - + private static final Pattern WORD_RE = Pattern.compile( + "\\w+", + Pattern.UNICODE_CHARACTER_CLASS | Pattern.MULTILINE + ); } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/WordWrapFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/WordWrapFilter.java index 492b0808e..47f3f9483 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/WordWrapFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/WordWrapFilter.java @@ -1,30 +1,45 @@ package com.hubspot.jinjava.lib.filter; -import java.util.Objects; - -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.apache.commons.lang3.text.WordUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Objects; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.commons.lang3.text.WordUtils; @JinjavaDoc( - value = "Return a copy of the string passed to the filter wrapped after 79 characters.", - params = { - @JinjavaParam(value = "s", desc = "String to wrap after a certain number of chracters"), - @JinjavaParam(value = "width", type = "number", defaultValue = "79", desc = "Sets the width of spaces at which to wrap the text"), - @JinjavaParam(value = "break_long_words", type = "boolean", defaultValue = "True", desc = "If true, long words will be broken when wrapped") - }, - snippets = { - @JinjavaSnippet( - desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
-            code = "
\n" +
-                "    {{ \"Lorem ipsum dolor sit amet, consectetur adipiscing elit\"|wordwrap(10) }}\n" +
-                "
") - }) + value = "Return a copy of the string passed to the filter wrapped after 79 characters.", + input = @JinjavaParam( + value = "s", + type = "string", + desc = "String to wrap after a certain number of characters", + required = true + ), + params = { + @JinjavaParam( + value = "width", + type = "number", + defaultValue = "79", + desc = "Sets the width of spaces at which to wrap the text" + ), + @JinjavaParam( + value = "break_long_words", + type = "boolean", + defaultValue = "True", + desc = "If true, long words will be broken when wrapped" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Since HubSpot's compiler automatically strips whitespace, this filter will only work in tags where whitespace is retained, such as a
",
+      code = "
\n" +
+      "    {{ \"Lorem ipsum dolor sit amet, consectetur adipiscing elit\"|wordwrap(10) }}\n" +
+      "
" + ), + } +) public class WordWrapFilter implements Filter { @Override @@ -48,5 +63,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return WordUtils.wrap(str, wrapLength, "\n", wrapLongWords); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/XmlAttrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/XmlAttrFilter.java index ebc331c40..7c25766b1 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/XmlAttrFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/XmlAttrFilter.java @@ -1,32 +1,48 @@ package com.hubspot.jinjava.lib.filter; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; - +import java.util.regex.Pattern; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; -import com.hubspot.jinjava.doc.annotations.JinjavaDoc; -import com.hubspot.jinjava.doc.annotations.JinjavaParam; -import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @JinjavaDoc( - value = "Create an HTML/XML attribute string based on the items in a dict.", - params = { - @JinjavaParam(value = "d", type = "dict", desc = "Dict to filter"), - @JinjavaParam(value = "autospace", type = "boolean", defaultValue = "True", desc = "Automatically prepend a space in front of the item") - }, - snippets = { - @JinjavaSnippet( - code = "{% set html_attributes = {'class': 'bold', 'id': 'sidebar'} %}\n" + - "
") - }) + value = "Create an HTML/XML attribute string based on the items in a dict.", + input = @JinjavaParam( + value = "dict", + type = "dict", + desc = "Dict to filter", + required = true + ), + params = { + @JinjavaParam( + value = "autospace", + type = "boolean", + defaultValue = "True", + desc = "Automatically prepend a space in front of the item" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% set html_attributes = {'class': 'bold', 'id': 'sidebar'} %}\n" + + "
" + ), + } +) public class XmlAttrFilter implements Filter { + // See https://html.spec.whatwg.org/#attribute-name-state Don't allow characters that would change the attribute name/value state + private static final Pattern ILLEGAL_ATTRIBUTE_KEY_PATTERN = Pattern.compile( + "[\\s/>=]" + ); + @Override public String getName() { return "xmlattr"; @@ -43,11 +59,18 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) List attrs = new ArrayList<>(); for (Map.Entry entry : dict.entrySet()) { - attrs.add(new StringBuilder(entry.getKey()) + if (ILLEGAL_ATTRIBUTE_KEY_PATTERN.matcher(entry.getKey()).find()) { + throw new IllegalArgumentException( + String.format("Invalid character in attribute name: %s", entry.getKey()) + ); + } + attrs.add( + new StringBuilder(entry.getKey()) .append("=\"") .append(StringEscapeUtils.escapeXml10(Objects.toString(entry.getValue(), ""))) .append("\"") - .toString()); + .toString() + ); } String space = " "; @@ -57,5 +80,4 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) return space + StringUtils.join(attrs, "\n"); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/time/DateTimeFormatHelper.java b/src/main/java/com/hubspot/jinjava/lib/filter/time/DateTimeFormatHelper.java new file mode 100644 index 000000000..4fa1786db --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/time/DateTimeFormatHelper.java @@ -0,0 +1,128 @@ +package com.hubspot.jinjava.lib.filter.time; + +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureActivationStrategy; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.date.InvalidDateFormatException; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.IllformedLocaleException; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; + +public final class DateTimeFormatHelper { + + public static final String FIXED_DATE_TIME_FILTER_NULL_ARG = + BuiltInFeatures.FIXED_DATE_TIME_FILTER_NULL_ARG; + private final String name; + private final Function cannedFormatterFunction; + + DateTimeFormatHelper( + String name, + Function cannedFormatterFunction + ) { + this.name = name; + this.cannedFormatterFunction = cannedFormatterFunction; + } + + String format(Object var, String... args) { + String format = arg(args, 0).orElse("medium"); + ZoneId zoneId = arg(args, 1).map(this::parseZone).orElse(ZoneOffset.UTC); + Locale locale = arg(args, 2) + .map(this::parseLocale) + .orElseGet(() -> + JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getLocale) + .orElse(Locale.ENGLISH) + ); + + return buildFormatter(format) + .withLocale(locale) + .format(Functions.getDateTimeArg(var, zoneId)); + } + + private static Optional arg(String[] args, int index) { + return args.length > index ? Optional.ofNullable(args[index]) : Optional.empty(); + } + + private ZoneId parseZone(String zone) { + try { + return ZoneId.of(zone); + } catch (DateTimeException e) { + throw new InvalidArgumentException( + JinjavaInterpreter.getCurrent(), + name, + "Invalid time zone: " + zone + ); + } + } + + private Locale parseLocale(String locale) { + try { + return new Locale.Builder().setLanguageTag(locale).build(); + } catch (IllformedLocaleException e) { + throw new InvalidArgumentException( + JinjavaInterpreter.getCurrent(), + name, + "Invalid locale: " + locale + ); + } + } + + private DateTimeFormatter buildFormatter(String format) { + switch (format) { + case "short": + return cannedFormatterFunction.apply(FormatStyle.SHORT); + case "medium": + return cannedFormatterFunction.apply(FormatStyle.MEDIUM); + case "long": + return cannedFormatterFunction.apply(FormatStyle.LONG); + case "full": + return cannedFormatterFunction.apply(FormatStyle.FULL); + default: + try { + return DateTimeFormatter.ofPattern(format); + } catch (IllegalArgumentException e) { + throw new InvalidDateFormatException(format, e); + } + } + } + + public Object checkForNullVar(Object var, String name) { + if (var != null) { + return var; + } + + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + + interpreter.addError( + TemplateError.fromMissingFilterArgException( + new InvalidArgumentException( + interpreter, + name, + name + " filter called with null datetime" + ) + ) + ); + + FeatureActivationStrategy feat = interpreter + .getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.FIXED_DATE_TIME_FILTER_NULL_ARG); + + return feat.isActive(interpreter.getContext()) + ? ((DateTimeFeatureActivationStrategy) feat).getActivateAt() + : null; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilter.java new file mode 100644 index 000000000..5b3908b59 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilter.java @@ -0,0 +1,63 @@ +package com.hubspot.jinjava.lib.filter.time; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; +import java.time.format.DateTimeFormatter; + +@JinjavaDoc( + value = "Formats the date component of a date object", + input = @JinjavaParam( + value = "value", + desc = "The date object or Unix timestamp to format", + required = true + ), + params = { + @JinjavaParam( + value = "format", + defaultValue = "medium", + desc = "The format to use. One of 'short', 'medium', 'long', 'full', or a custom pattern following Unicode LDML\nhttps://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns" + ), + @JinjavaParam( + value = "timeZone", + defaultValue = "UTC", + desc = "Time zone of the output date in IANA TZDB format\nhttps://data.iana.org/time-zones/tzdb/" + ), + @JinjavaParam( + value = "locale", + defaultValue = "Locale specified on JinjavaConfig", + desc = "The locale to use for locale-aware formats" + ), + }, + snippets = { + @JinjavaSnippet(code = "{{ content.updated | format_date('long') }}"), + @JinjavaSnippet(code = "{{ content.updated | format_date('yyyyy.MMMM.dd') }}"), + @JinjavaSnippet( + code = "{{ content.updated | format_date('medium', 'America/New_York', 'de-DE') }}" + ), + } +) +public class FormatDateFilter implements Filter { + + private static final String NAME = "format_date"; + private static final DateTimeFormatHelper HELPER = new DateTimeFormatHelper( + NAME, + DateTimeFormatter::ofLocalizedDate + ); + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return format(var, args); + } + + public static Object format(Object var, String... args) { + return HELPER.format(HELPER.checkForNullVar(var, NAME), args); + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilter.java new file mode 100644 index 000000000..5de652fac --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilter.java @@ -0,0 +1,65 @@ +package com.hubspot.jinjava.lib.filter.time; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; +import java.time.format.DateTimeFormatter; + +@JinjavaDoc( + value = "Formats both the date and time components of a date object", + input = @JinjavaParam( + value = "value", + desc = "The date object or Unix timestamp to format", + required = true + ), + params = { + @JinjavaParam( + value = "format", + defaultValue = "medium", + desc = "The format to use. One of 'short', 'medium', 'long', 'full', or a custom pattern following Unicode LDML\nhttps://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns" + ), + @JinjavaParam( + value = "timeZone", + defaultValue = "UTC", + desc = "Time zone of the output date in IANA TZDB format\nhttps://data.iana.org/time-zones/tzdb/" + ), + @JinjavaParam( + value = "locale", + defaultValue = "Locale specified on JinjavaConfig", + desc = "The locale to use for locale-aware formats" + ), + }, + snippets = { + @JinjavaSnippet(code = "{{ content.updated | format_datetime('long') }}"), + @JinjavaSnippet( + code = "{{ content.updated | format_datetime('yyyyy.MMMM.dd GGG hh:mm a') }}" + ), + @JinjavaSnippet( + code = "{{ content.updated | format_datetime('medium', 'America/New_York', 'de-DE') }}" + ), + } +) +public class FormatDatetimeFilter implements Filter { + + private static final String NAME = "format_datetime"; + private static final DateTimeFormatHelper HELPER = new DateTimeFormatHelper( + NAME, + DateTimeFormatter::ofLocalizedDateTime + ); + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return format(var, args); + } + + public static Object format(Object var, String... args) { + return HELPER.format(HELPER.checkForNullVar(var, NAME), args); + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilter.java new file mode 100644 index 000000000..6133c457d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilter.java @@ -0,0 +1,63 @@ +package com.hubspot.jinjava.lib.filter.time; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; +import java.time.format.DateTimeFormatter; + +@JinjavaDoc( + value = "Formats the time component of a date object", + input = @JinjavaParam( + value = "value", + desc = "The date object or Unix timestamp to format", + required = true + ), + params = { + @JinjavaParam( + value = "format", + defaultValue = "medium", + desc = "The format to use. One of 'short', 'medium', 'long', 'full', or a custom pattern following Unicode LDML\nhttps://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns" + ), + @JinjavaParam( + value = "timeZone", + defaultValue = "UTC", + desc = "Time zone of the output date in IANA TZDB format\nhttps://data.iana.org/time-zones/tzdb/" + ), + @JinjavaParam( + value = "locale", + defaultValue = "Locale specified on JinjavaConfig", + desc = "The locale to use for locale-aware formats" + ), + }, + snippets = { + @JinjavaSnippet(code = "{{ content.updated | format_time('long') }}"), + @JinjavaSnippet(code = "{{ content.updated | format_time('hh:mm a') }}"), + @JinjavaSnippet( + code = "{{ content.updated | format_time('medium', 'America/New_York', 'de-DE') }}" + ), + } +) +public class FormatTimeFilter implements Filter { + + private static final String NAME = "format_time"; + private static final DateTimeFormatHelper HELPER = new DateTimeFormatHelper( + NAME, + DateTimeFormatter::ofLocalizedTime + ); + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return format(var, args); + } + + public static Object format(Object var, String... args) { + return HELPER.format(HELPER.checkForNullVar(var, NAME), args); + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/ELFunctionDefinition.java b/src/main/java/com/hubspot/jinjava/lib/fn/ELFunctionDefinition.java index e5cc8cda7..a11b0d833 100644 --- a/src/main/java/com/hubspot/jinjava/lib/fn/ELFunctionDefinition.java +++ b/src/main/java/com/hubspot/jinjava/lib/fn/ELFunctionDefinition.java @@ -1,9 +1,8 @@ package com.hubspot.jinjava.lib.fn; -import java.lang.reflect.Method; - import com.google.common.base.Throwables; import com.hubspot.jinjava.lib.Importable; +import java.lang.reflect.Method; public class ELFunctionDefinition implements Importable { @@ -11,19 +10,30 @@ public class ELFunctionDefinition implements Importable { private String localName; private Method method; - public ELFunctionDefinition(String namespace, String localName, Class methodClass, String methodName, Class... parameterTypes) { + public ELFunctionDefinition( + String namespace, + String localName, + Class methodClass, + String methodName, + Class... parameterTypes + ) { this.namespace = namespace; this.localName = localName; this.method = resolveMethod(methodClass, methodName, parameterTypes); } - private static Method resolveMethod(Class methodClass, String methodName, Class... parameterTypes) { + private static Method resolveMethod( + Class methodClass, + String methodName, + Class... parameterTypes + ) { try { Method m = methodClass.getDeclaredMethod(methodName, parameterTypes); m.setAccessible(true); return m; } catch (Exception e) { - throw Throwables.propagate(e); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); } } @@ -49,5 +59,4 @@ public String getName() { public Method getMethod() { return method; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/FunctionLibrary.java b/src/main/java/com/hubspot/jinjava/lib/fn/FunctionLibrary.java index 80ccae6b6..f5990c4da 100644 --- a/src/main/java/com/hubspot/jinjava/lib/fn/FunctionLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/fn/FunctionLibrary.java @@ -2,22 +2,148 @@ import com.google.common.collect.Lists; import com.hubspot.jinjava.lib.SimpleLibrary; +import com.hubspot.jinjava.lib.filter.time.FormatDateFilter; +import com.hubspot.jinjava.lib.filter.time.FormatDatetimeFilter; +import com.hubspot.jinjava.lib.filter.time.FormatTimeFilter; +import java.util.Set; public class FunctionLibrary extends SimpleLibrary { - public FunctionLibrary(boolean registerDefaults) { - super(registerDefaults); + public FunctionLibrary(boolean registerDefaults, Set disabled) { + super(registerDefaults, disabled); } @Override protected void registerDefaults() { - register(new ELFunctionDefinition("", "datetimeformat", Functions.class, "dateTimeFormat", Object.class, String[].class)); - register(new ELFunctionDefinition("", "truncate", Functions.class, "truncate", Object.class, Object[].class)); + register( + new ELFunctionDefinition( + "", + "datetimeformat", + Functions.class, + "dateTimeFormat", + Object.class, + String[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "format_date", + FormatDateFilter.class, + "format", + Object.class, + String[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "format_time", + FormatTimeFilter.class, + "format", + Object.class, + String[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "format_datetime", + FormatDatetimeFilter.class, + "format", + Object.class, + String[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "unixtimestamp", + Functions.class, + "unixtimestamp", + Object[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "truncate", + Functions.class, + "truncate", + Object.class, + Object[].class + ) + ); + register( + new ELFunctionDefinition( + "", + "range", + Functions.class, + "range", + Object.class, + Object[].class + ) + ); + register( + new ELFunctionDefinition("", "type", TypeFunction.class, "type", Object.class) + ); + register( + new ELFunctionDefinition("", "today", Functions.class, "today", String[].class) + ); + register( + new ELFunctionDefinition( + "", + "strtotime", + Functions.class, + Functions.STRING_TO_TIME_FUNCTION, + String.class, + String.class + ) + ); + register( + new ELFunctionDefinition( + "", + "strtodate", + Functions.class, + Functions.STRING_TO_DATE_FUNCTION, + String.class, + String.class + ) + ); register(new ELFunctionDefinition("", "super", Functions.class, "renderSuperBlock")); + register( + new ELFunctionDefinition( + "", + "namespace", + Functions.class, + "createNamespace", + Object[].class + ) + ); - register(new ELFunctionDefinition("fn", "list", Lists.class, "newArrayList", Object[].class)); - register(new ELFunctionDefinition("fn", "immutable_list", Functions.class, "immutableListOf", Object[].class)); + register( + new ELFunctionDefinition("fn", "list", Lists.class, "newArrayList", Object[].class) + ); + register( + new ELFunctionDefinition( + "fn", + "immutable_list", + Functions.class, + "immutableListOf", + Object[].class + ) + ); + register( + new ELFunctionDefinition( + "fn", + "map_entry", + Functions.class, + "convertToMapEntry", + Object.class, + Object.class + ) + ); } public void addFunction(ELFunctionDefinition fn) { @@ -27,5 +153,4 @@ public void addFunction(ELFunctionDefinition fn) { public ELFunctionDefinition getFunction(String name) { return fetch(name); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/Functions.java b/src/main/java/com/hubspot/jinjava/lib/fn/Functions.java index 28f0f7682..3bf154bfe 100644 --- a/src/main/java/com/hubspot/jinjava/lib/fn/Functions.java +++ b/src/main/java/com/hubspot/jinjava/lib/fn/Functions.java @@ -1,41 +1,73 @@ package com.hubspot.jinjava.lib.fn; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.BooleanUtils; - import com.google.common.collect.Lists; +import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.el.ext.NamedParameter; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureActivationStrategy; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.mode.ExecutionMode; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.objects.date.DateTimeProvider; import com.hubspot.jinjava.objects.date.PyishDate; import com.hubspot.jinjava.objects.date.StrftimeFormatter; import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.math.NumberUtils; public class Functions { - @JinjavaDoc(value = "Only usable within blocks, will render the contents of the parent block by calling super.", snippets = { - @JinjavaSnippet(desc = "This gives back the results of the parent block", code = "{% block sidebar %}\n" + - "

Table Of Contents

\n\n" + - " ...\n" + - " {{ super() }}\n" + - "{% endblock %}") - }) + public static final String STRING_TO_TIME_FUNCTION = "stringToTime"; + public static final String STRING_TO_DATE_FUNCTION = "stringToDate"; + + public static final int DEFAULT_RANGE_LIMIT = 1000; + + @JinjavaDoc( + value = "Only usable within blocks, will render the contents of the parent block by calling super.", + snippets = { + @JinjavaSnippet( + desc = "This gives back the results of the parent block", + code = "{% block sidebar %}\n" + + "

Table Of Contents

\n\n" + + " ...\n" + + " {{ super() }}\n" + + "{% endblock %}" + ), + } + ) public static String renderSuperBlock() { JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); - StringBuilder result = new StringBuilder(); + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); - @SuppressWarnings("unchecked") - List superBlock = (List) interpreter.getContext().get("__superbl0ck__"); + List superBlock = interpreter.getContext().getSuperBlock(); if (superBlock != null) { for (Node n : superBlock) { result.append(n.render(interpreter)); @@ -45,49 +77,384 @@ public static String renderSuperBlock() { return result.toString(); } + @SuppressWarnings("unchecked") + @JinjavaDoc( + value = "Create a namespace object that can hold arbitrary attributes." + + "It may be initialized from a dictionary or with keyword arguments.", + params = { + @JinjavaParam( + value = "dictionary", + type = "Map", + desc = "The dictionary to initialize with" + ), + @JinjavaParam( + value = "kwargs", + type = "NamedParameter...", + desc = "Keyword arguments to put into the namespace dictionary" + ), + }, + snippets = { + @JinjavaSnippet(code = "{% set ns = namespace() %}"), + @JinjavaSnippet(code = "{% set ns = namespace(b=false) %}"), + @JinjavaSnippet(code = "{% set ns = namespace(my_map, b=false) %}"), + } + ) + public static Namespace createNamespace(Object... parameters) { + Namespace namespace = parameters.length > 0 && parameters[0] instanceof Map + ? new Namespace( + (Map) parameters[0], + JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getMaxMapSize()) + .orElse(Integer.MAX_VALUE) + ) + : new Namespace( + new HashMap<>(), + JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getMaxMapSize()) + .orElse(Integer.MAX_VALUE) + ); + namespace.putAll( + Arrays + .stream(parameters) + .filter(p -> + p instanceof NamedParameter && ((NamedParameter) p).getValue() != null + ) + .map(p -> (NamedParameter) p) + .collect(Collectors.toMap(NamedParameter::getName, NamedParameter::getValue)) + ); + return namespace; + } + public static List immutableListOf(Object... items) { return Collections.unmodifiableList(Lists.newArrayList(items)); } - @JinjavaDoc(value = "formats a date to a string", params = { - @JinjavaParam(value = "var", type = "date", defaultValue = "current time"), - @JinjavaParam(value = "format", defaultValue = StrftimeFormatter.DEFAULT_DATE_FORMAT) - }) + @JinjavaDoc( + value = "converts a key-value pair into a Map.Entry", + params = { + @JinjavaParam(value = "key", type = "object"), + @JinjavaParam(value = "value", type = "object"), + }, + hidden = true + ) + public static Map.Entry convertToMapEntry(Object key, Object value) { + return new SimpleEntry<>(key, value); + } + + @JinjavaDoc( + value = "return datetime of beginning of the day", + params = { + @JinjavaParam( + value = "timezone", + type = "string", + defaultValue = "utc", + desc = "timezone" + ), + } + ) + public static ZonedDateTime today(String... var) { + ZoneId zoneOffset = ZoneOffset.UTC; + if (var.length > 0 && var[0] != null) { + String timezone = var[0]; + try { + zoneOffset = ZoneId.of(timezone); + } catch (DateTimeException e) { + throw new InvalidArgumentException( + JinjavaInterpreter.getCurrent(), + "today", + String.format("Invalid timezone: %s", timezone) + ); + } + } + long currentMillis = JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getDateTimeProvider) + .map(DateTimeProvider::getCurrentTimeMillis) + .orElse(System.currentTimeMillis()); + ZonedDateTime dateTime = getDateTimeArg(currentMillis, zoneOffset); + return dateTime.toLocalDate().atStartOfDay(zoneOffset); + } + + @JinjavaDoc( + value = "formats a date to a string", + params = { + @JinjavaParam(value = "var", type = "date", required = true), + @JinjavaParam( + value = "format", + defaultValue = StrftimeFormatter.DEFAULT_DATE_FORMAT + ), + @JinjavaParam( + value = "timezone", + defaultValue = "utc", + desc = "Time zone of output date" + ), + } + ) public static String dateTimeFormat(Object var, String... format) { + ZoneId zoneOffset = ZoneId.of("UTC"); + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + + if (format.length > 1 && format[1] != null) { + String timezone = format[1]; + try { + zoneOffset = ZoneId.of(timezone); + } catch (DateTimeException e) { + throw new InvalidArgumentException( + interpreter, + "datetimeformat", + String.format("Invalid timezone: %s", timezone) + ); + } + } else if (var instanceof ZonedDateTime) { + zoneOffset = ((ZonedDateTime) var).getZone(); + } else if (var instanceof PyishDate) { + zoneOffset = ((PyishDate) var).toDateTime().getZone(); + } + + if (var == null) { + interpreter.addError( + TemplateError.fromMissingFilterArgException( + new InvalidArgumentException( + interpreter, + "datetimeformat", + "datetimeformat filter called with null datetime" + ) + ) + ); + + FeatureActivationStrategy feat = interpreter + .getConfig() + .getFeatures() + .getActivationStrategy(BuiltInFeatures.FIXED_DATE_TIME_FILTER_NULL_ARG); + + if (feat.isActive(interpreter.getContext())) { + var = ((DateTimeFeatureActivationStrategy) feat).getActivateAt(); + } + } + + ZonedDateTime d = getDateTimeArg(var, zoneOffset); + if (d == null) { + return ""; + } + + Locale locale; + if (format.length > 2 && format[2] != null) { + locale = Locale.forLanguageTag(format[2]); + } else { + locale = + JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getLocale) + .orElse(Locale.ENGLISH); + } + + if (format.length > 0) { + return StrftimeFormatter.format(d, format[0], locale); + } else { + return StrftimeFormatter.format(d, locale); + } + } + + public static ZonedDateTime getDateTimeArg(Object var, ZoneId zoneOffset) { ZonedDateTime d = null; if (var == null) { - d = ZonedDateTime.now(ZoneOffset.UTC); + if ( + JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getExecutionMode) + .map(ExecutionMode::useEagerParser) + .orElse(false) + ) { + throw new DeferredValueException( + "Time is deferred", + JinjavaInterpreter.getCurrent().getLineNumber(), + JinjavaInterpreter.getCurrent().getPosition() + ); + } + long currentMillis = JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getDateTimeProvider) + .map(DateTimeProvider::getCurrentTimeMillis) + .orElse(System.currentTimeMillis()); + + d = ZonedDateTime.ofInstant(Instant.ofEpochMilli(currentMillis), zoneOffset); } else if (var instanceof Number) { - d = ZonedDateTime.ofInstant(Instant.ofEpochMilli(((Number) var).longValue()), ZoneOffset.UTC); + d = + ZonedDateTime.ofInstant( + Instant.ofEpochMilli(((Number) var).longValue()), + zoneOffset + ); } else if (var instanceof PyishDate) { - d = ((PyishDate) var).toDateTime(); + PyishDate pyishDate = ((PyishDate) var); + d = pyishDate.toDateTime(); + d = d.withZoneSameInstant(zoneOffset); } else if (var instanceof ZonedDateTime) { d = (ZonedDateTime) var; + d = d.withZoneSameInstant(zoneOffset); } else if (!ZonedDateTime.class.isAssignableFrom(var.getClass())) { - throw new InterpretException("Input to datetimeformat function must be a date object, was: " + var.getClass()); + throw new InterpretException( + "Input to function must be a date object, was: " + var.getClass() + ); } + return d; + } + + @JinjavaDoc( + value = "gets the unix timestamp milliseconds value of a datetime", + params = { @JinjavaParam(value = "var", type = "date", required = true) } + ) + public static long unixtimestamp(Object... var) { + Object filterVar = var == null || var.length == 0 ? null : var[0]; + + ZonedDateTime d = getDateTimeArg(filterVar, ZoneOffset.UTC); + if (d == null) { - return ""; + return 0; } - if (format.length > 0) { - return StrftimeFormatter.format(d, format[0]); - } else { - return StrftimeFormatter.format(d); + return d.toEpochSecond() * 1000 + d.getNano() / 1_000_000; + } + + @JinjavaDoc( + value = "converts a string and datetime format into a datetime object", + params = { + @JinjavaParam(value = "var", type = "datetimeString", desc = "datetime as string"), + @JinjavaParam( + value = "var", + type = "datetimeFormat", + desc = "format of the datetime string" + ), + } + ) + public static PyishDate stringToTime(String datetimeString, String datetimeFormat) { + if (datetimeString == null) { + return null; + } + + if (datetimeFormat == null) { + throw new InterpretException( + String.format("%s() requires non-null datetime format", STRING_TO_TIME_FUNCTION) + ); + } + + try { + return new PyishDate( + ZonedDateTime.parse( + datetimeString, + StrftimeFormatter.toDateTimeFormatter(datetimeFormat) + ) + ); + } catch (DateTimeParseException e) { + throw new InterpretException( + String.format( + "%s() could not match datetime input %s with datetime format %s", + STRING_TO_TIME_FUNCTION, + datetimeString, + datetimeFormat + ) + ); + } catch (IllegalArgumentException e) { + throw new InterpretException( + String.format( + "%s() requires valid datetime format, was %s", + STRING_TO_TIME_FUNCTION, + datetimeFormat + ) + ); + } + } + + @JinjavaDoc( + value = "converts a string and date format into a date object", + params = { + @JinjavaParam(value = "dateString", type = "string", desc = "date as string"), + @JinjavaParam( + value = "dateFormat", + type = "string", + desc = "format of the date string" + ), + } + ) + public static PyishDate stringToDate(String dateString, String dateFormat) { + if (dateString == null) { + return null; + } + + if (dateFormat == null) { + throw new InterpretException( + String.format("%s() requires non-null date format", STRING_TO_DATE_FUNCTION) + ); + } + + try { + return new PyishDate( + LocalDate + .parse(dateString, StrftimeFormatter.toDateTimeFormatter(dateFormat)) + .atTime(0, 0) + .toInstant(ZoneOffset.UTC) + ); + } catch (DateTimeParseException e) { + throw new InterpretException( + String.format( + "%s() could not match date input %s with date format %s", + STRING_TO_DATE_FUNCTION, + dateString, + dateFormat + ) + ); + } catch (IllegalArgumentException e) { + throw new InterpretException( + String.format( + "%s() requires valid date format, was %s", + STRING_TO_DATE_FUNCTION, + dateFormat + ) + ); } } private static final int DEFAULT_TRUNCATE_LENGTH = 255; private static final String DEFAULT_END = "..."; - @JinjavaDoc(value = "truncates a given string to a specified length", params = { - @JinjavaParam("s"), - @JinjavaParam(value = "length", type = "number", defaultValue = "255"), - @JinjavaParam(value = "end", defaultValue = "...") - }) + @JinjavaDoc( + value = "truncates a given string to a specified length", + params = { + @JinjavaParam( + value = "string", + type = "string", + desc = "String to be truncated", + required = true + ), + @JinjavaParam( + value = "length", + type = "number", + defaultValue = "255", + desc = "Specifies the length at which to truncate the text (includes HTML characters)" + ), + @JinjavaParam( + value = "killwords", + type = "boolean", + defaultValue = "False", + desc = "If true, the string will cut text at length" + ), + @JinjavaParam( + value = "end", + defaultValue = "...", + desc = "The characters that will be added to indicate where the text was truncated" + ), + } + ) public static Object truncate(Object var, Object... arg) { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + if (var instanceof String) { int length = DEFAULT_TRUNCATE_LENGTH; boolean killwords = false; @@ -97,7 +464,19 @@ public static Object truncate(Object var, Object... arg) { try { length = Integer.parseInt(Objects.toString(arg[0])); } catch (Exception e) { - ENGINE_LOG.warn("truncate(): error setting length for {}, using default {}", arg[0], DEFAULT_TRUNCATE_LENGTH); + interpreter.addError( + TemplateError.fromInvalidArgumentException( + new InvalidArgumentException( + interpreter, + "truncate", + String.format( + "truncate(): error setting length of %s, using default of %d", + arg[0], + DEFAULT_TRUNCATE_LENGTH + ) + ) + ) + ); } } @@ -105,7 +484,15 @@ public static Object truncate(Object var, Object... arg) { try { killwords = BooleanUtils.toBoolean(Objects.toString(arg[1])); } catch (Exception e) { - ENGINE_LOG.warn("truncate(); error setting killwords for {}", arg[1]); + interpreter.addError( + TemplateError.fromInvalidArgumentException( + new InvalidArgumentException( + interpreter, + "truncate", + String.format("truncate(): error setting killwords for %s", arg[1]) + ) + ) + ); } } @@ -119,6 +506,7 @@ public static Object truncate(Object var, Object... arg) { if (!killwords) { length = movePointerToJustBeforeLastWord(length, string); } + length = Math.max(length, 0); return string.substring(0, length) + ends; } else { @@ -139,4 +527,86 @@ public static int movePointerToJustBeforeLastWord(int pointer, String s) { return pointer + 1; } + @JinjavaDoc( + value = "Return a list containing an arithmetic progression of integers. " + + "With one parameter, range will return a list from 0 up to (but not including) the value. " + + "With two parameters, the range will start at the first value and increment by 1 up to (but not including) the second value. " + + "The third parameter specifies the step increment. All values can be negative. Impossible ranges will return an empty list. " + + "Ranges can generate a maximum of " + + DEFAULT_RANGE_LIMIT + + " values.", + params = { + @JinjavaParam(value = "start", type = "number", defaultValue = "0"), + @JinjavaParam(value = "end", type = "number"), + @JinjavaParam(value = "step", type = "number", defaultValue = "1"), + } + ) + public static List range(Object arg1, Object... args) { + int rangeLimit = JinjavaInterpreter.getCurrent().getConfig().getRangeLimit(); + List result = new ArrayList<>(); + + int start = 0; + int end = 0; + int step = 1; + + if (arg1 == null) { + throw new InvalidArgumentException( + JinjavaInterpreter.getCurrent(), + "range", + "Invalid null passed to range function" + ); + } + + switch (args.length) { + case 0: + if (NumberUtils.isCreatable(arg1.toString())) { + end = NumberUtils.toInt(arg1.toString(), rangeLimit); + } + break; + case 1: + start = NumberUtils.toInt(arg1.toString()); + if (args[0] != null && NumberUtils.isCreatable(args[0].toString())) { + end = NumberUtils.toInt(args[0].toString(), start + rangeLimit); + } + break; + default: + start = NumberUtils.toInt(arg1.toString()); + if (args[0] != null && NumberUtils.isCreatable(args[0].toString())) { + end = NumberUtils.toInt(args[0].toString(), start + rangeLimit); + } + if (args[1] != null) { + step = NumberUtils.toInt(args[1].toString(), 1); + } + } + + if (step == 0) { + return result; + } + + if (start < end) { + if (step < 0) { + return result; + } + + for (int i = start; i < end; i += step) { + if (result.size() >= rangeLimit) { + break; + } + result.add(i); + } + } else { + if (step > 0) { + return result; + } + + for (int i = start; i > end; i += step) { + if (result.size() >= rangeLimit) { + break; + } + result.add(i); + } + } + + return result; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxy.java b/src/main/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxy.java index c193fa216..bc4f86ce0 100644 --- a/src/main/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxy.java +++ b/src/main/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxy.java @@ -2,9 +2,9 @@ import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; +import com.google.common.base.Throwables; import java.lang.reflect.Method; import java.lang.reflect.Modifier; - import javassist.ClassPool; import javassist.CtClass; import javassist.CtField; @@ -12,34 +12,62 @@ import javassist.CtNewMethod; import javassist.bytecode.AccessFlag; -import com.google.common.base.Throwables; - public class InjectedContextFunctionProxy { - public static ELFunctionDefinition defineProxy(String namespace, String name, Method m, Object injectedInstance) - { + private static final String GUICE_CLASS_INDICATOR = "$$EnhancerByGuice$$"; + + public static ELFunctionDefinition defineProxy( + String namespace, + String name, + Method m, + Object injectedInstance + ) { + Class injectedInstanceClass = removeGuiceWrapping(injectedInstance.getClass()); try { ClassPool pool = ClassPool.getDefault(); - String ccName = InjectedContextFunctionProxy.class.getSimpleName() + "$$" + namespace + "$$" + name; + String ccName = String.format( + "%s$$%s$$%s$$%s", + injectedInstanceClass.getName(), + InjectedContextFunctionProxy.class.getSimpleName(), + namespace, + name + ); Class injectedClass = null; try { - injectedClass = InjectedContextFunctionProxy.class.getClassLoader().loadClass(ccName); + injectedClass = + InjectedContextFunctionProxy.class.getClassLoader().loadClass(ccName); } catch (ClassNotFoundException e) { CtClass cc = pool.makeClass(ccName); CtClass mc = pool.get(m.getDeclaringClass().getName()); - CtField injectedField = CtField.make(String.format("public static %s injectedField;", m.getDeclaringClass().getName()), cc); + CtField injectedField = CtField.make( + String.format( + "public static %s injectedField;", + m.getDeclaringClass().getName() + ), + cc + ); cc.addField(injectedField); - CtField injectedMethod = CtField.make(String.format("public static %s delegate;", Method.class.getName()), cc); + CtField injectedMethod = CtField.make( + String.format("public static %s delegate;", Method.class.getName()), + cc + ); cc.addField(injectedMethod); CtMethod ctMethod = mc.getDeclaredMethod(m.getName()); - CtMethod invokeMethod = CtNewMethod.make(Modifier.PUBLIC | Modifier.STATIC, ctMethod.getReturnType(), "invoke", - ctMethod.getParameterTypes(), ctMethod.getExceptionTypes(), null, cc); + CtMethod invokeMethod = CtNewMethod.make( + Modifier.PUBLIC | Modifier.STATIC, + ctMethod.getReturnType(), + "invoke", + ctMethod.getParameterTypes(), + ctMethod.getExceptionTypes(), + null, + cc + ); invokeMethod.setBody("{ return $proceed($$); }", "injectedField", m.getName()); for (CtClass param : ctMethod.getParameterTypes()) { @@ -69,8 +97,17 @@ public static ELFunctionDefinition defineProxy(String namespace, String name, Me return new ELFunctionDefinition(namespace, name, staticMethod); } catch (Throwable e) { ENGINE_LOG.error("Error creating injected context function", e); - throw Throwables.propagate(e); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); } } + public static Class removeGuiceWrapping(Class clazz) { + if ( + clazz.getName().contains(GUICE_CLASS_INDICATOR) && clazz.getSuperclass() != null + ) { + clazz = clazz.getSuperclass(); + } + return clazz; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/MacroFunction.java b/src/main/java/com/hubspot/jinjava/lib/fn/MacroFunction.java index 95c910881..8b51cbc47 100644 --- a/src/main/java/com/hubspot/jinjava/lib/fn/MacroFunction.java +++ b/src/main/java/com/hubspot/jinjava/lib/fn/MacroFunction.java @@ -1,13 +1,21 @@ package com.hubspot.jinjava.lib.fn; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - import com.hubspot.jinjava.el.ext.AbstractCallableMethod; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValue; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; /** * Function definition parsed from a jinjava template, stored in global macros registry in interpreter context. @@ -17,71 +25,222 @@ */ public class MacroFunction extends AbstractCallableMethod { - private final List content; + public static final String KWARGS_KEY = "kwargs"; + public static final String VARARGS_KEY = "varargs"; + protected final List content; + + protected final boolean caller; + + protected final Context localContextScope; + + protected final int definitionLineNumber; - private final boolean catchKwargs; - private final boolean catchVarargs; - private final boolean caller; + protected final int definitionStartPosition; - private final Context localContextScope; + protected boolean deferred; - public MacroFunction(List content, String name, LinkedHashMap argNamesWithDefaults, - boolean catchKwargs, boolean catchVarargs, boolean caller, Context localContextScope) { + public MacroFunction( + List content, + String name, + LinkedHashMap argNamesWithDefaults, + boolean caller, + Context localContextScope, + int lineNumber, + int startPosition + ) { super(name, argNamesWithDefaults); this.content = content; - this.catchKwargs = catchKwargs; - this.catchVarargs = catchVarargs; this.caller = caller; this.localContextScope = localContextScope; + this.definitionLineNumber = lineNumber; + this.definitionStartPosition = startPosition; + this.deferred = false; + } + + public MacroFunction(MacroFunction source, String name) { + super(name, (LinkedHashMap) source.getDefaults()); + this.content = source.content; + this.caller = source.caller; + this.localContextScope = source.localContextScope; + this.definitionLineNumber = source.definitionLineNumber; + this.definitionStartPosition = source.definitionStartPosition; + this.deferred = source.deferred; } @Override - public Object doEvaluate(Map argMap, Map kwargMap, List varArgs) { + public Object doEvaluate( + Map argMap, + Map kwargMap, + List varArgs + ) { JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + try ( + InterpreterScopeClosable c = interpreter.enterScope(); + AutoCloseableImpl> importFile = getImportFileWithWrapper( + interpreter + ) + .get() + ) { + return getEvaluationResult(argMap, kwargMap, varArgs, interpreter); + } + } + + public Optional getImportFile(JinjavaInterpreter interpreter) { + return getImportFileWithWrapper(interpreter).dangerouslyGetWithoutClosing(); + } - interpreter.enterScope(); + public AutoCloseableSupplier> getImportFileWithWrapper( + JinjavaInterpreter interpreter + ) { + Optional importFile = Optional.ofNullable( + (String) localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY) + ); + if (importFile.isEmpty()) { + return AutoCloseableSupplier.of(Optional.empty()); + } + return interpreter + .getContext() + .getCurrentPathStack() + .closeablePushWithoutCycleCheck( + importFile.get(), + interpreter.getLineNumber(), + interpreter.getPosition() + ) + .map(Optional::of); + } - try { - for (Map.Entry scopeEntry : localContextScope.getScope().entrySet()) { + public String getEvaluationResult( + Map argMap, + Map kwargMap, + List varArgs, + JinjavaInterpreter interpreter + ) { + interpreter.setLineNumber(definitionLineNumber); + interpreter.setPosition(definitionStartPosition); + if ( + !Objects.equals( + interpreter.getContext().get(Context.IMPORT_RESOURCE_PATH_KEY), + localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY) + ) + ) { + for (Map.Entry scopeEntry : localContextScope + .getScope() + .entrySet()) { if (scopeEntry.getValue() instanceof MacroFunction) { interpreter.getContext().addGlobalMacro((MacroFunction) scopeEntry.getValue()); + } else if (scopeEntry.getKey().equals(Context.GLOBAL_MACROS_SCOPE_KEY)) { + interpreter + .getContext() + .put( + Context.GLOBAL_MACROS_SCOPE_KEY, + new HashMap<>((Map) scopeEntry.getValue()) + ); + } else { + if (!alreadyDeferredInEarlierCall(scopeEntry.getKey(), interpreter)) { + if ( + interpreter.getContext().get(scopeEntry.getKey()) == scopeEntry.getValue() + ) { + continue; // don't override if it's the same object + } + interpreter.getContext().put(scopeEntry.getKey(), scopeEntry.getValue()); + } } - else { - interpreter.getContext().put(scopeEntry.getKey(), scopeEntry.getValue()); - } - } - - // named parameters - for (Map.Entry argEntry : argMap.entrySet()) { - interpreter.getContext().put(argEntry.getKey(), argEntry.getValue()); } - // parameter map - interpreter.getContext().put("kwargs", argMap); - // varargs list - interpreter.getContext().put("varargs", varArgs); - - StringBuilder result = new StringBuilder(); + } + // named parameters + for (Map.Entry argEntry : argMap.entrySet()) { + interpreter.getContext().put(argEntry.getKey(), argEntry.getValue()); + } + // parameter map + interpreter.getContext().put(KWARGS_KEY, kwargMap); + // varargs list + interpreter.getContext().put(VARARGS_KEY, varArgs); + + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + try ( + TemporaryValueClosable c = interpreter.getContext().withUnwrapRawOverride() + ) { for (Node node : content) { result.append(node.render(interpreter)); } - - return result.toString(); - } finally { - interpreter.leaveScope(); } + return result.toString(); } - public boolean isCatchKwargs() { - return catchKwargs; + public void setDeferred(boolean deferred) { + this.deferred = deferred; } - public boolean isCatchVarargs() { - return catchVarargs; + public boolean isDeferred() { + return deferred; } public boolean isCaller() { return caller; } + public String reconstructImage() { + if (content != null && !content.isEmpty()) { + return content.get(0).getParent().reconstructImage(); + } + return ""; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MacroFunction that = (MacroFunction) o; + return ( + caller == that.caller && + Objects.equals(getName(), that.getName()) && + Objects.equals( + localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY), + that.localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY) + ) + ); + } + + @Override + public int hashCode() { + return Objects.hash( + getName(), + localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY), + caller + ); + } + + public MacroFunction cloneWithNewName(String name) { + return new MacroFunction(this, name); + } + + private boolean alreadyDeferredInEarlierCall( + String key, + JinjavaInterpreter interpreter + ) { + if (interpreter.getContext().get(key) instanceof DeferredValue) { + Context penultimateParent = interpreter.getContext().getPenultimateParent(); + String importResourcePath = (String) localContextScope.get( + Context.IMPORT_RESOURCE_PATH_KEY + ); + return penultimateParent + .getDeferredTokens() + .stream() + .filter(deferredToken -> + Objects.equals(importResourcePath, deferredToken.getImportResourcePath()) + ) + .anyMatch(deferredToken -> + deferredToken.getSetDeferredBases().contains(key) || + deferredToken.getUsedDeferredBases().contains(key) + ); + } + return false; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/TypeFunction.java b/src/main/java/com/hubspot/jinjava/lib/fn/TypeFunction.java new file mode 100644 index 000000000..d2a387949 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/fn/TypeFunction.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.lib.fn; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.el.ext.AstDict; +import com.hubspot.jinjava.el.ext.AstList; +import com.hubspot.jinjava.el.ext.AstTuple; +import com.hubspot.jinjava.objects.SafeString; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +@JinjavaDoc( + value = "Get a string that describes the type of the object, similar to Python's type()" +) +public class TypeFunction { + + private static Map, String> CLASS_TYPE_TO_NAME = ImmutableMap + ., String>builder() + .put(AstDict.class, "dict") + .put(AstList.class, "list") + .put(AstTuple.class, "tuple") + .put(Boolean.class, "bool") + .put(PyishDate.class, "datetime") + .put(ZonedDateTime.class, "datetime") + .build(); + + private static Map, String> ASSIGNABLE_TYPE_TO_NAME = ImmutableMap + ., String>builder() + .put(Boolean.class, "bool") + .put(Double.class, "float") + .put(Float.class, "float") + .put(Integer.class, "int") + .put(List.class, "list") + .put(Long.class, "long") + .put(Map.class, "dict") + .put(String.class, "str") + .put(SafeString.class, "str") + .build(); + + public static String type(Object var) { + if (var == null) { + return "null"; + } + + for (Entry, String> entry : CLASS_TYPE_TO_NAME.entrySet()) { + if (var.getClass() == entry.getKey()) { + return entry.getValue(); + } + } + + for (Entry, String> entry : ASSIGNABLE_TYPE_TO_NAME.entrySet()) { + if (entry.getKey().isAssignableFrom(var.getClass())) { + return entry.getValue(); + } + } + + return "unknown"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunction.java b/src/main/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunction.java new file mode 100644 index 000000000..18a649794 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunction.java @@ -0,0 +1,385 @@ +package com.hubspot.jinjava.lib.fn.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.el.ext.AstMacroFunction; +import com.hubspot.jinjava.el.ext.DeferredInvocationResolutionException; +import com.hubspot.jinjava.el.ext.eager.MacroFunctionTempVariable; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredMacroValueImpl; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.tag.MacroTag; +import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerContextWatcher.EagerChildContextConfig; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerMacroFunction extends MacroFunction { + + private AtomicInteger callCount = new AtomicInteger(); + private AtomicBoolean reconstructing = new AtomicBoolean(); + + public EagerMacroFunction( + List content, + String name, + LinkedHashMap argNamesWithDefaults, + boolean caller, + Context localContextScope, + int lineNumber, + int startPosition + ) { + super( + content, + name, + argNamesWithDefaults, + caller, + localContextScope, + lineNumber, + startPosition + ); + } + + EagerMacroFunction(MacroFunction source, String name) { + super(source, name); + } + + public Object doEvaluate( + Map argMap, + Map kwargMap, + List varArgs + ) { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + if (reconstructing.get()) { + try ( + InterpreterScopeClosable c = interpreter.enterScope(); + AutoCloseableImpl> importFile = getImportFileWithWrapper( + interpreter + ) + .get() + ) { + EagerExecutionResult result = eagerEvaluateInDeferredExecutionMode( + () -> getEvaluationResultDirectly(argMap, kwargMap, varArgs, interpreter), + interpreter + ); + if (!result.getResult().isFullyResolved()) { + interpreter + .getContext() + .removeDeferredTokens(interpreter.getContext().getDeferredTokens()); + result = + eagerEvaluateInDeferredExecutionMode( + () -> getEvaluationResultDirectly(argMap, kwargMap, varArgs, interpreter), + interpreter + ); + } + return result.asTemplateString(); + } + } + + int currentCallCount = callCount.getAndIncrement(); + EagerExecutionResult eagerExecutionResult = eagerEvaluate( + () -> super.doEvaluate(argMap, kwargMap, varArgs).toString(), + EagerChildContextConfig + .newBuilder() + .withCheckForContextChanges(!interpreter.getContext().isDeferredExecutionMode()) + .withTakeNewValue(true) + .build(), + interpreter + ); + if ( + !eagerExecutionResult.getResult().isFullyResolved() && + (!interpreter.getContext().isPartialMacroEvaluation() || + !eagerExecutionResult.getSpeculativeBindings().isEmpty() || + interpreter.getContext().isDeferredExecutionMode()) + ) { + PrefixToPreserveState prefixToPreserveState = + EagerReconstructionUtils.resetAndDeferSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + + String tempVarName = MacroFunctionTempVariable.getVarName( + getName(), + hashCode(), + currentCallCount + ); + interpreter + .getContext() + .getParent() + .put( + tempVarName, + new MacroFunctionTempVariable( + prefixToPreserveState + eagerExecutionResult.asTemplateString() + ) + ); + throw new DeferredInvocationResolutionException(tempVarName); + } + if (!eagerExecutionResult.getResult().isFullyResolved()) { + return EagerReconstructionUtils.wrapInChildScope( + eagerExecutionResult.getResult().toString(true), + interpreter + ); + } + return eagerExecutionResult.getResult().toString(true); + } + + private String getEvaluationResultDirectly( + Map argMap, + Map kwargMap, + List varArgs, + JinjavaInterpreter interpreter + ) { + String evaluationResult = getEvaluationResult(argMap, kwargMap, varArgs, interpreter); + interpreter.getContext().getScope().remove(KWARGS_KEY); + interpreter.getContext().getScope().remove(VARARGS_KEY); + return evaluationResult; + } + + private EagerExecutionResult eagerEvaluateInDeferredExecutionMode( + Supplier stringSupplier, + JinjavaInterpreter interpreter + ) { + return eagerEvaluate( + stringSupplier, + EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .withTakeNewValue(true) + .withCheckForContextChanges(true) + .build(), + interpreter + ); + } + + private EagerExecutionResult eagerEvaluate( + Supplier stringSupplier, + EagerChildContextConfig eagerChildContextConfig, + JinjavaInterpreter interpreter + ) { + return EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromSupplier(stringSupplier, eagerInterpreter), + interpreter, + eagerChildContextConfig + ); + } + + private String getStartTag(String fullName, JinjavaInterpreter interpreter) { + StringJoiner argJoiner = new StringJoiner(", "); + for (String arg : getArguments()) { + if (getDefaults().get(arg) != null) { + argJoiner.add( + String.format( + "%s=%s", + arg, + PyishObjectMapper.getAsPyishString(getDefaults().get(arg)) + ) + ); + continue; + } + argJoiner.add(arg); + } + return new StringJoiner(" ") + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(MacroTag.TAG_NAME) + .add(String.format("%s(%s)", fullName, argJoiner.toString())) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()) + .toString(); + } + + private String getEndTag(JinjavaInterpreter interpreter) { + return new StringJoiner(" ") + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(String.format("end%s", MacroTag.TAG_NAME)) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()) + .toString(); + } + + @Override + public String reconstructImage() { + return reconstructImage(getName()); + } + + /** + * Reconstruct the image of the macro function, @see MacroFunction#reconstructImage() + * This image, however, may be partially or fully resolved depending on the + * usage of the arguments, which are filled in as deferred values, and any values on + * this interpreter's context. + * @return An image of the macro function that's body is resolved as much as possible. + * This image allows for the macro function to be recreated during a later + * rendering pass. + */ + public String reconstructImage(String fullName) { + String prefix = ""; + StringBuilder result = new StringBuilder(); + String setTagForAliasedVariables = getSetTagForAliasedVariables(fullName); + String suffix = ""; + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + + Optional importFile = Optional.ofNullable( + (String) localContextScope.get(Context.IMPORT_RESOURCE_PATH_KEY) + ); + Object currentDeferredImportResource = null; + if (importFile.isPresent()) { + currentDeferredImportResource = + interpreter.getContext().get(Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY); + if (currentDeferredImportResource instanceof DeferredValue) { + currentDeferredImportResource = + ((DeferredValue) currentDeferredImportResource).getOriginalValue(); + } + prefix = + EagerReconstructionUtils.buildBlockOrInlineSetTag( + Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY, + importFile.get(), + interpreter + ); + interpreter + .getContext() + .put(Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY, importFile.get()); + suffix = + EagerReconstructionUtils.buildBlockOrInlineSetTag( + Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY, + currentDeferredImportResource, + interpreter + ); + } + + if ( + interpreter.getContext().getMacroStack().contains(getName()) && + !differentMacroWithSameNameExists(interpreter) + ) { + return ""; + } + + try ( + AutoCloseableImpl shouldReconstruct = shouldDoReconstruction( + fullName, + interpreter + ) + ) { + if (!shouldReconstruct.value()) { + return ""; + } + + try (InterpreterScopeClosable c = interpreter.enterScope()) { + reconstructing.set(true); + String evaluation = (String) evaluate( + getArguments().stream().map(arg -> DeferredMacroValueImpl.instance()).toArray() + ); + result + .append(getStartTag(fullName, interpreter)) + .append(setTagForAliasedVariables) + .append(evaluation) + .append(getEndTag(interpreter)); + } catch (DeferredValueException e) { + // In case something not eager-supported encountered a deferred value + if (StringUtils.isNotEmpty(setTagForAliasedVariables)) { + throw new DeferredValueException( + "Aliased variables in not eagerly reconstructible macro function" + ); + } + result.append(super.reconstructImage()); + } finally { + reconstructing.set(false); + interpreter + .getContext() + .put(Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY, currentDeferredImportResource); + } + } + + return prefix + result + suffix; + } + + private AutoCloseableImpl shouldDoReconstruction( + String fullName, + JinjavaInterpreter interpreter + ) { + return ( + isCaller() + ? AutoCloseableSupplier.of(true) + : AstMacroFunction + .checkAndPushMacroStackWithWrapper(interpreter, fullName) + .map(result -> + result.match( + err -> false, // cycle detected, don't do reconstruction + ok -> true // no cycle, proceed with reconstruction + ) + ) + ).get(); + } + + private String getSetTagForAliasedVariables(String fullName) { + int lastDotIdx = fullName.lastIndexOf('.'); + if (lastDotIdx > 0) { + String aliasName = fullName.substring(0, lastDotIdx + 1); + Map namesToAlias = localContextScope + .getCombinedScope() + .entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof DeferredValue) + .map(Entry::getKey) + .collect(Collectors.toMap(Function.identity(), name -> aliasName + name)); + return EagerReconstructionUtils.buildSetTag( + namesToAlias, + JinjavaInterpreter.getCurrent(), + false + ); + } + return ""; + } + + private boolean differentMacroWithSameNameExists(JinjavaInterpreter interpreter) { + Context context = interpreter.getContext(); + if (context.getParent() == null) { + return false; + } + MacroFunction mostRecent = context.getGlobalMacro(getName()); + if (this != mostRecent) { + return true; + } + while ( + !context.getGlobalMacros().containsKey(getName()) && + context.getParent().getParent() != null + ) { + context = context.getParent(); + } + MacroFunction secondMostRecent = context.getParent().getGlobalMacro(getName()); + return secondMostRecent != null && secondMostRecent != this; + } + + @Override + public boolean equals(Object o) { + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + @Override + public EagerMacroFunction cloneWithNewName(String name) { + return new EagerMacroFunction(this, name); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/AutoEscapeTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/AutoEscapeTag.java index 2c0c37c86..5804258e8 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/AutoEscapeTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/AutoEscapeTag.java @@ -1,56 +1,58 @@ package com.hubspot.jinjava.lib.tag; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Autoescape the tag's contents", - hidden = true, - snippets = { - @JinjavaSnippet( - code = "{% autoescape %}\n" + - "
Code to escape
\n" + - "{% endautoescape %}" - ) - }) + value = "Autoescape the tag's contents", + hidden = true, + snippets = { + @JinjavaSnippet( + code = "{% autoescape %}\n" + "
Code to escape
\n" + "{% endautoescape %}" + ), + } +) public class AutoEscapeTag implements Tag { - public static final String AUTOESCAPE_CONTEXT_VAR = "__auto3sc@pe__"; + + public static final String TAG_NAME = "autoescape"; + private static final long serialVersionUID = 786006577642541285L; @Override public String getName() { - return "autoescape"; + return TAG_NAME; } @Override - public String getEndTagName() { - return "endautoescape"; + public boolean isRenderedInValidationMode() { + return true; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - interpreter.enterScope(); - try { + try (InterpreterScopeClosable c = interpreter.enterScope()) { String boolFlagStr = StringUtils.trim(tagNode.getHelpers()); - boolean escapeFlag = BooleanUtils.toBoolean(StringUtils.isNotBlank(boolFlagStr) ? boolFlagStr : "true"); - interpreter.getContext().put(AUTOESCAPE_CONTEXT_VAR, escapeFlag); + boolean escapeFlag = BooleanUtils.toBoolean( + StringUtils.isNotBlank(boolFlagStr) ? boolFlagStr : "true" + ); + interpreter.getContext().setAutoEscape(escapeFlag); - StringBuilder result = new StringBuilder(); + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); for (Node child : tagNode.getChildren()) { result.append(child.render(interpreter)); } return result.toString(); - } finally { - interpreter.leaveScope(); } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java index 2c4dce2f7..24cabb2cd 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java @@ -1,28 +1,31 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.output.BlockInfo; +import com.hubspot.jinjava.tree.output.BlockPlaceholderOutputNode; +import com.hubspot.jinjava.tree.output.OutputNode; import com.hubspot.jinjava.util.HelperStringTokenizer; import com.hubspot.jinjava.util.WhitespaceUtils; @@ -31,44 +34,66 @@ * */ @JinjavaDoc( - value = "Blocks are regions in a template which can be overridden by child templates", - params = { - @JinjavaParam(value = "block_name", desc = "A unique name for the block that should be used in both the parent and child template") - }, - snippets = { - @JinjavaSnippet( - code = "{% extends \"custom/page/web_page_basic/my_template.html\" %}\n" + - "{% block my_sidebar %}\n" + - " \n" + - "{% endblock %}"), - }) + value = "Blocks are regions in a template which can be overridden by child templates", + params = { + @JinjavaParam( + value = "block_name", + desc = "A unique name for the block that should be used in both the parent and child template" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% extends \"custom/page/web_page_basic/my_template.html\" %}\n" + + "{% block my_sidebar %}\n" + + " \n" + + "{% endblock %}" + ), + } +) +@JinjavaHasCodeBody +@JinjavaTextMateSnippet(code = "{% block ${1:name} %}\n$0\n{% endblock $1 %}") public class BlockTag implements Tag { + public static final String TAG_NAME = "block"; + private static final long serialVersionUID = -2362317415797088108L; - private static final String TAGNAME = "block"; - private static final String ENDTAGNAME = "endblock"; @Override - public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - List helper = new HelperStringTokenizer(tagNode.getHelpers()).allTokens(); - if (helper.isEmpty()) { - throw new InterpretException("Tag 'block' expects an identifier", tagNode.getLineNumber()); + public OutputNode interpretOutput(TagNode tagNode, JinjavaInterpreter interpreter) { + HelperStringTokenizer tagData = new HelperStringTokenizer(tagNode.getHelpers()); + if (!tagData.hasNext()) { + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'block' expects an identifier", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } - String blockName = WhitespaceUtils.unquote(helper.get(0)); + String blockName = WhitespaceUtils.unquoteAndUnescape(tagData.next()); - interpreter.addBlock(blockName, tagNode.getChildren()); - return JinjavaInterpreter.BLOCK_STUB_START + blockName + JinjavaInterpreter.BLOCK_STUB_END; + interpreter.addBlock( + blockName, + new BlockInfo( + tagNode.getChildren(), + interpreter.getContext().getCurrentPathStack().peek(), + interpreter.getContext().getCurrentPathStack().getTopLineNumber(), + interpreter.getContext().getCurrentPathStack().getTopStartPosition() + ) + ); + + return new BlockPlaceholderOutputNode(blockName); } @Override - public String getEndTagName() { - return ENDTAGNAME; + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + throw new UnsupportedOperationException( + "BlockTag must be rendered directly via interpretOutput() method" + ); } @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java new file mode 100644 index 000000000..55a795f81 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.NotInLoopException; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.ForLoop; + +/** + * Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension + * @author ccutrer + */ + +@JinjavaDoc( + value = "Stops executing the current for loop, including any further iterations" +) +@JinjavaTextMateSnippet( + code = "{% for item in [1, 2, 3, 4] %}{% if item > 2 == 0 %}{% break %}{% endif %}{{ item }}{% endfor %}" +) +public class BreakTag implements Tag { + + public static final String TAG_NAME = "break"; + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Object loop = interpreter.getContext().get(ForTag.LOOP); + if (loop instanceof ForLoop) { + if (interpreter.getContext().isDeferredExecutionMode()) { + throw new DeferredValueException("Deferred break"); + } + ForLoop forLoop = (ForLoop) loop; + forLoop.doBreak(); + } else if (loop instanceof DeferredValue) { + throw new DeferredValueException("Deferred break"); + } else { + throw new NotInLoopException(TAG_NAME); + } + return ""; + } + + @Override + public String getEndTagName() { + return null; + } + + @Override + public boolean isRenderedInValidationMode() { + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/CallTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/CallTag.java index 6f3bfd74a..fa4294e87 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/CallTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/CallTag.java @@ -1,80 +1,86 @@ package com.hubspot.jinjava.lib.tag; -import java.util.LinkedHashMap; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; import com.hubspot.jinjava.lib.fn.MacroFunction; import com.hubspot.jinjava.tree.TagNode; +import java.util.LinkedHashMap; @JinjavaDoc( - value = "In some cases it can be useful to pass a macro to another macro. For this purpose you can use the special call block.", - snippets = { - @JinjavaSnippet( - desc = "This is a simple dialog rendered by using a macro and a call block", - code = " {% macro render_dialog(title, class='dialog') %}\n" + - "
\n" + - "

{{ title }}

\n" + - "
\n" + - " {{ caller() }}\n" + - "
\n" + - "
\n" + - " {% endmacro %}\n\n" + - - " {% call render_dialog('Hello World') %}\n" + - " This is a simple dialog rendered by using a macro and\n" + - " a call block.\n" + - " {% endcall %}"), - @JinjavaSnippet( - desc = "It’s also possible to pass arguments back to the call block. This makes it useful as replacement for loops. " - + "Generally speaking a call block works exactly like an macro, just that it doesn’t have a name. Here an example " - + "of how a call block can be used with arguments", - code = " {% macro dump_users(users) %}\n" + - "
    \n" + - " {% for user in users %}\n" + - "
  • {{ user.username|e }}

    {{ caller(user) }}
  • \n" + - " {%- endfor %}\n" + - "
\n" + - " {% endmacro %}\n\n" + - - " {% call(user) dump_users(list_of_user) %}\n" + - "
\n" + - "
Realname
\n" + - "
{{ user.realname|e }}
\n" + - "
Description
\n" + - "
{{ user.description }}
\n" + - "
\n" + - " {% endcall %}") - }) + value = "In some cases it can be useful to pass a macro to another macro. For this purpose you can use the special call block.", + snippets = { + @JinjavaSnippet( + desc = "This is a simple dialog rendered by using a macro and a call block", + code = " {% macro render_dialog(title, class='dialog') %}\n" + + "
\n" + + "

{{ title }}

\n" + + "
\n" + + " {{ caller() }}\n" + + "
\n" + + "
\n" + + " {% endmacro %}\n\n" + + " {% call render_dialog('Hello World') %}\n" + + " This is a simple dialog rendered by using a macro and\n" + + " a call block.\n" + + " {% endcall %}" + ), + @JinjavaSnippet( + desc = "It’s also possible to pass arguments back to the call block. This makes it useful as replacement for loops. " + + "Generally speaking a call block works exactly like an macro, just that it doesn’t have a name. Here an example " + + "of how a call block can be used with arguments", + code = " {% macro dump_users(users) %}\n" + + "
    \n" + + " {% for user in users %}\n" + + "
  • {{ user.username|e }}

    {{ caller(user) }}
  • \n" + + " {%- endfor %}\n" + + "
\n" + + " {% endmacro %}\n\n" + + " {% call(user) dump_users(list_of_user) %}\n" + + "
\n" + + "
Realname
\n" + + "
{{ user.realname|e }}
\n" + + "
Description
\n" + + "
{{ user.description }}
\n" + + "
\n" + + " {% endcall %}" + ), + } +) +@JinjavaTextMateSnippet( + code = "{% call ${1:macro_name}(${2:argument_names}) %}\n" + "$0\n" + "{% endcall %}" +) public class CallTag implements Tag { + public static final String TAG_NAME = "call"; + private static final long serialVersionUID = 7231253469979314727L; @Override public String getName() { - return "call"; - } - - @Override - public String getEndTagName() { - return "endcall"; + return TAG_NAME; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { String macroExpr = "{{" + tagNode.getHelpers().trim() + "}}"; - interpreter.enterScope(); - try { + try (InterpreterScopeClosable c = interpreter.enterNonStackingScope()) { LinkedHashMap args = new LinkedHashMap<>(); - MacroFunction caller = new MacroFunction(tagNode.getChildren(), "caller", args, false, false, true, interpreter.getContext()); + MacroFunction caller = new MacroFunction( + tagNode.getChildren(), + "caller", + args, + true, + interpreter.getContext(), + interpreter.getLineNumber(), + interpreter.getPosition() + ); interpreter.getContext().addGlobalMacro(caller); return interpreter.render(macroExpr); - } finally { - interpreter.leaveScope(); } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java new file mode 100644 index 000000000..4d74aad5f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.NotInLoopException; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.ForLoop; + +/** + * Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension + * @author ccutrer + */ + +@JinjavaDoc(value = "Stops executing the current iteration of the current for loop") +@JinjavaTextMateSnippet( + code = "{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}" +) +public class ContinueTag implements Tag { + + public static final String TAG_NAME = "continue"; + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Object loop = interpreter.getContext().get(ForTag.LOOP); + if (loop instanceof ForLoop) { + if (interpreter.getContext().isDeferredExecutionMode()) { + throw new DeferredValueException("Deferred continue"); + } + ForLoop forLoop = (ForLoop) loop; + forLoop.doContinue(); + } else if (loop instanceof DeferredValue) { + throw new DeferredValueException("Deferred continue"); + } else { + throw new NotInLoopException(TAG_NAME); + } + return ""; + } + + @Override + public String getEndTagName() { + return null; + } + + @Override + public boolean isRenderedInValidationMode() { + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/CycleTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/CycleTag.java index 3516a4a14..a6f305936 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/CycleTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/CycleTag.java @@ -1,29 +1,29 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.util.List; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.util.HelperStringTokenizer; +import java.util.List; /** * {% cycle a,b,c %} {% cycle a,'b',c as d %} {% cycle d %} @@ -33,22 +33,34 @@ */ @JinjavaDoc( - value = "The cycle tag can be used within a for loop to cycle through a series of string values and print them with each iteration", - params = { - @JinjavaParam(value = "string_to_print", desc = "A comma separated list of strings to print with each interation. The list will repeat if there are more iterations than string parameter values.") - }, - snippets = { - @JinjavaSnippet( - desc = "In the example below, a class of \"odd\" and \"even\" and even are applied to posts in a listing", - code = "{% for content in contents %}\n" + - "
Blog post content
\n" + - "{% endfor %}"), - }) + value = "The cycle tag can be used within a for loop to cycle through a series of string values and print them with each iteration", + params = { + @JinjavaParam( + value = "string_to_print", + desc = "A comma separated list of strings to print with each interation. The list will repeat if there are more iterations than string parameter values." + ), + }, + snippets = { + @JinjavaSnippet( + desc = "In the example below, a class of \"odd\" and \"even\" and even are applied to posts in a listing", + code = "{% for content in contents %}\n" + + "
Blog post content
\n" + + "{% endfor %}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% cycle '${1:string_to_print}' %}") public class CycleTag implements Tag { + public static final String TAG_NAME = "cycle"; + public static final String LOOP_INDEX = "loop.index0"; + private static final long serialVersionUID = 9145890505287556784L; - private static final String LOOP_INDEX = "loop.index0"; - private static final String TAGNAME = "cycle"; + + @Override + public boolean isRenderedInValidationMode() { + return true; + } @SuppressWarnings("unchecked") @Override @@ -62,19 +74,39 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { HelperStringTokenizer items = new HelperStringTokenizer(helper.get(0)); items.splitComma(true); values = items.allTokens(); - Integer forindex = (Integer) interpreter.retraceVariable(LOOP_INDEX, tagNode.getLineNumber()); + Integer forindex = (Integer) interpreter.retraceVariable( + LOOP_INDEX, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); if (forindex == null) { forindex = 0; } if (values.size() == 1) { var = values.get(0); - values = (List) interpreter.retraceVariable(var, tagNode.getLineNumber()); + values = + (List) interpreter.retraceVariable( + var, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); if (values == null) { - return interpreter.resolveString(var, tagNode.getLineNumber()); + return interpreter.resolveString( + var, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } } else { for (int i = 0; i < values.size(); i++) { - values.set(i, interpreter.resolveString(values.get(i), tagNode.getLineNumber())); + values.set( + i, + interpreter.resolveString( + values.get(i), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ) + ); } } return values.get(forindex % values.size()); @@ -83,13 +115,25 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { items.splitComma(true); values = items.allTokens(); for (int i = 0; i < values.size(); i++) { - values.set(i, interpreter.resolveString(values.get(i), tagNode.getLineNumber())); + values.set( + i, + interpreter.resolveString( + values.get(i), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ) + ); } var = helper.get(2); interpreter.getContext().put(var, values); return ""; } else { - throw new InterpretException("Tag 'cycle' expects 1 or 3 helper(s) >>> " + helper.size(), tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'cycle' expects 1 or 3 helper(s), was: " + helper.size(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } } @@ -100,7 +144,6 @@ public String getEndTagName() { @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/DoTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/DoTag.java new file mode 100644 index 000000000..3fce9a353 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/DoTag.java @@ -0,0 +1,48 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import org.apache.commons.lang3.StringUtils; + +@JinjavaDoc( + value = "Evaluates expression without printing out result.", + snippets = { + @JinjavaSnippet(code = "{% do list.append('value 2') %}"), + @JinjavaSnippet( + desc = "Execute a block of code in the same scope while ignoring the output", + code = "{% do %}\n" + + "{% set foo = [] %}\n" + + "{{ foo.append('a') }}\n" + + "{% enddo %}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% do ${1:expr} %}") +public class DoTag implements Tag, FlexibleTag { + + public static final String TAG_NAME = "do"; + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + if (hasEndTag((TagToken) tagNode.getMaster())) { + tagNode.getChildren().forEach(child -> child.render(interpreter)); + } else { + interpreter.resolveELExpression(tagNode.getHelpers(), tagNode.getLineNumber()); + } + return ""; + } + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public boolean hasEndTag(TagToken tagToken) { + return StringUtils.isBlank(tagToken.getHelpers()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ElseIfTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ElseIfTag.java index 0561489bf..bc1063aa0 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ElseIfTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ElseIfTag.java @@ -1,14 +1,33 @@ package com.hubspot.jinjava.lib.tag; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.tree.TagNode; -@JinjavaDoc(value = "", hidden = true) +@JinjavaDoc( + value = "", + params = { + @JinjavaParam( + value = "condition", + type = "conditional expression", + desc = "An expression that evaluates to either true or false" + ), + }, + hidden = true +) +@JinjavaHasCodeBody public class ElseIfTag implements Tag { + public static final String TAG_NAME = "elif"; + private static final long serialVersionUID = -7988057025956316803L; - static final String ELSEIF = "elif"; + + @Override + public boolean isRenderedInValidationMode() { + return true; + } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { @@ -22,7 +41,6 @@ public String getEndTagName() { @Override public String getName() { - return ELSEIF; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ElseTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ElseTag.java index 78d59eec2..bc08314a6 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ElseTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ElseTag.java @@ -16,14 +16,22 @@ package com.hubspot.jinjava.lib.tag; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.tree.TagNode; @JinjavaDoc(value = "", hidden = true) +@JinjavaHasCodeBody public class ElseTag implements Tag { + public static final String TAG_NAME = "else"; + private static final long serialVersionUID = 1082768429113702148L; - static final String ELSE = "else"; + + @Override + public boolean isRenderedInValidationMode() { + return true; + } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { @@ -37,7 +45,6 @@ public String getEndTagName() { @Override public String getName() { - return ELSE; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/EndTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/EndTag.java index 20baad409..748a25c5e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/EndTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/EndTag.java @@ -28,5 +28,4 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { public String getEndTagName() { return endTagName; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java index 3eb52669f..a17bbea02 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java @@ -1,99 +1,132 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.io.IOException; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.util.HelperStringTokenizer; +import java.io.IOException; -@JinjavaDoc(value = "Template inheritance allows you to build a base “skeleton” template that contains all the " - + "common elements of your site and defines blocks that child templates can override.", - params = { - @JinjavaParam(value = "path", desc = "Design Manager file path to parent template") - }, - snippets = { - @JinjavaSnippet( - desc = "This template, which we’ll call base.html, defines a simple HTML skeleton document that you might " - + "use for a simple two-column page. It’s the job of “child” templates to fill the empty blocks with content.\n\n" - + "In this example, the {% block %} tags define four blocks that child templates can fill in. All the block tag " - + "does is tell the template engine that a child template may override those placeholders in the template.", - code = "\n" + - "\n" + - "\n" + - " {% block head %}\n" + - " \n" + - " {% block title %}{% endblock %} - My Webpage\n" + - " {% endblock %}\n" + - "\n" + - "\n" + - "
{% block content %}{% endblock %}
\n" + - "
\n" + - " {% block footer %}\n" + - " © Copyright 2008 by you.\n" + - " {% endblock %}\n" + - "
\n" + - "\n" + - ""), - @JinjavaSnippet( - desc = "The {% extends %} tag is the key here. It tells the template engine that this template “extends” another template. " - + "When the template system evaluates this template, it first locates the parent. The extends tag should be the first " - + "tag in the template. Everything before it is printed out normally and may cause confusion.", - code = "{% extends \"custom/page/web_page_basic/my_template.html\" %}\n" + - "{% block title %}Index{% endblock %}\n" + - "{% block head %}\n" + - " {{ super() }}\n" + - " \n" + - "{% endblock %}\n" + - "{% block content %}\n" + - "

Index

\n" + - "

\n" + - " Welcome to my awesome homepage.\n" + - "

\n" + - "{% endblock %}") - }) +@JinjavaDoc( + value = "Template inheritance allows you to build a base “skeleton” template that contains all the " + + "common elements of your site and defines blocks that child templates can override.", + params = { + @JinjavaParam(value = "path", desc = "Design Manager file path to parent template"), + }, + snippets = { + @JinjavaSnippet( + desc = "This template, which we’ll call base.html, defines a simple HTML skeleton document that you might " + + "use for a simple two-column page. It’s the job of “child” templates to fill the empty blocks with content.\n\n" + + "In this example, the {% block %} tags define four blocks that child templates can fill in. All the block tag " + + "does is tell the template engine that a child template may override those placeholders in the template.", + code = "\n" + + "\n" + + "\n" + + " {% block head %}\n" + + " \n" + + " {% block title %}{% endblock %} - My Webpage\n" + + " {% endblock %}\n" + + "\n" + + "\n" + + "
{% block content %}{% endblock %}
\n" + + "
\n" + + " {% block footer %}\n" + + " © Copyright 2008 by you.\n" + + " {% endblock %}\n" + + "
\n" + + "\n" + + "" + ), + @JinjavaSnippet( + desc = "The {% extends %} tag is the key here. It tells the template engine that this template “extends” another template. " + + "When the template system evaluates this template, it first locates the parent. The extends tag should be the first " + + "tag in the template. Everything before it is printed out normally and may cause confusion.", + code = "{% extends \"custom/page/web_page_basic/my_template.html\" %}\n" + + "{% block title %}Index{% endblock %}\n" + + "{% block head %}\n" + + " {{ super() }}\n" + + " \n" + + "{% endblock %}\n" + + "{% block content %}\n" + + "

Index

\n" + + "

\n" + + " Welcome to my awesome homepage.\n" + + "

\n" + + "{% endblock %}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% extends '${1:path}' %}") public class ExtendsTag implements Tag { + public static final String TAG_NAME = "extends"; + private static final long serialVersionUID = 4692863362280761393L; - private static final String TAGNAME = "extends"; @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + if (interpreter.getContext().isDeferredExecutionMode()) { + throw new DeferredValueException("extends tag"); + } HelperStringTokenizer tokenizer = new HelperStringTokenizer(tagNode.getHelpers()); if (!tokenizer.hasNext()) { - throw new InterpretException("Tag 'extends' expects template path", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'extends' expects template path", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } - String templateFile = interpreter.resolveString(tokenizer.next(), tagNode.getLineNumber()); + + String path = interpreter.resolveString( + tokenizer.next(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + path = interpreter.resolveResourceLocation(path); + interpreter + .getContext() + .getExtendPathStack() + .push(path, tagNode.getLineNumber(), tagNode.getStartPosition()); + try { - String template = interpreter.getResource(templateFile); + String template = interpreter.getResource(path); Node node = interpreter.parse(template); - JinjavaInterpreter child = new JinjavaInterpreter(interpreter); - child.getContext().addDependency("coded_files", templateFile); + + interpreter.getContext().addDependency("coded_files", path); interpreter.addExtendParentRoot(node); + return ""; } catch (IOException e) { - throw new InterpretException(e.getMessage(), e, tagNode.getLineNumber()); + throw new InterpretException( + e.getMessage(), + e, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } } @@ -104,7 +137,6 @@ public String getEndTagName() { @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/FlexibleTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/FlexibleTag.java new file mode 100644 index 000000000..2bf54990f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/FlexibleTag.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.tree.parse.TagToken; + +public interface FlexibleTag { + boolean hasEndTag(TagToken tagToken); +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java index 6ea3dd4eb..825a240b8 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java @@ -1,161 +1,349 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; - -import org.apache.commons.lang3.StringUtils; - import com.google.common.collect.Lists; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.NullValue; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.objects.DummyObject; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.tree.ExpressionNode; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.HelperStringTokenizer; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; import com.hubspot.jinjava.util.ObjectIterator; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.util.ConcurrentModificationException; +import java.util.List; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.tuple.Pair; /** * {% for a in b|f1:d,c %} - * - * {% for key, value in my_dict %} + *

+ * {% for key, value in my_dict.items() %} * * @author anysome - * */ @JinjavaDoc( - value = "Outputs the inner content for each item in the given iterable", - params = { - @JinjavaParam(value = "items_to_iterate", desc = "Specifies the name of a single item in the sequence or dict."), - }, - snippets = { - @JinjavaSnippet( - code = "{% for item in items %}\n" + - " {{ item }}\n" + - "{% endfor %}"), - @JinjavaSnippet( - desc = "Standard blog listing loop", - code = "{% for content in contents %}\n" + - " Post content variables\n" + - "{% endfor %}") - }) + value = "Outputs the inner content for each item in the given iterable", + params = { + @JinjavaParam( + value = "items_to_iterate", + desc = "Specifies the name of a single item in the sequence or dict." + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% for item in items %}\n" + " {{ item }}\n" + "{% endfor %}" + ), + @JinjavaSnippet( + desc = "Iterating over dictionary values", + code = "{% for value in dictionary %}\n" + " {{ value }}\n" + "{% endfor %}" + ), + @JinjavaSnippet( + desc = "Iterating over dictionary entries", + code = "{% for key, value in dictionary.items() %}\n" + + " {{ key }}: {{ value }}\n" + + "{% endfor %}" + ), + @JinjavaSnippet( + desc = "Standard blog listing loop", + code = "{% for content in contents %}\n" + + " Post content variables\n" + + "{% endfor %}" + ), + } +) +@JinjavaHasCodeBody +@JinjavaTextMateSnippet( + code = "{% for ${1:items} in ${2:list} %}\n" + "$0\n" + "{% endfor %}" +) public class ForTag implements Tag { + public static final String TAG_NAME = "for"; + + public static final String LOOP = "loop"; + private static final long serialVersionUID = 6175143875754966497L; - private static final String LOOP = "loop"; - private static final String TAGNAME = "for"; - private static final String ENDTAGNAME = "endfor"; + private static final Pattern IN_PATTERN = Pattern.compile("\\sin\\s"); + public static final String TOO_LARGE_EXCEPTION_MESSAGE = "Loop too large"; + + @Override + public boolean isRenderedInValidationMode() { + return true; + } @SuppressWarnings("unchecked") @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - List helper = new HelperStringTokenizer(tagNode.getHelpers()).splitComma(true).allTokens(); - - List loopVars = Lists.newArrayList(); - int inPos = 0; - while (inPos < helper.size()) { - String val = helper.get(inPos); + long numDeferredNodesBefore = interpreter + .getContext() + .getDeferredNodes() + .stream() + .filter(n -> !(n instanceof ExpressionNode)) + .count(); - if ("in".equals(val)) { - break; - } - else { - loopVars.add(val); - inPos++; - } + String result = interpretUnchecked(tagNode, interpreter); + if ( + interpreter + .getContext() + .getDeferredNodes() + .stream() + .filter(n -> !(n instanceof ExpressionNode)) + .count() > + numDeferredNodesBefore + ) { + throw new DeferredValueException( + "for loop", + interpreter.getLineNumber(), + interpreter.getPosition() + ); } + return result; + } - if (inPos >= helper.size()) { - throw new InterpretException("Tag 'for' expects valid 'in' clause, got: " + tagNode.getHelpers(), tagNode.getLineNumber()); - } + public String interpretUnchecked(TagNode tagNode, JinjavaInterpreter interpreter) { + Pair, String> loopVarsAndExpression = getLoopVarsAndExpression( + (TagToken) tagNode.getMaster() + ); + List loopVars = loopVarsAndExpression.getLeft(); + String loopExpression = loopVarsAndExpression.getRight(); + + Object collection = interpreter.resolveELExpression( + loopExpression, + tagNode.getLineNumber() + ); + return renderForCollection(tagNode, interpreter, loopVars, collection); + } - String loopExpr = StringUtils.join(helper.subList(inPos + 1, helper.size()), ","); - Object collection = interpreter.resolveELExpression(loopExpr, tagNode.getLineNumber()); + public String renderForCollection( + TagNode tagNode, + JinjavaInterpreter interpreter, + List loopVars, + Object collection + ) { ForLoop loop = ObjectIterator.getLoop(collection); - interpreter.enterScope(); - try { + try (InterpreterScopeClosable c = interpreter.enterScope()) { + if (interpreter.isValidationMode() && !loop.hasNext()) { + loop = ObjectIterator.getLoop(new DummyObject()); + interpreter.getContext().setValidationMode(true); + } + interpreter.getContext().put(LOOP, loop); - StringBuilder buff = new StringBuilder(); + LengthLimitingStringBuilder buff = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); while (loop.hasNext()) { - Object val = loop.next(); + Object val; + try { + val = interpreter.wrap(loop.next()); + } catch (ConcurrentModificationException e) { + interpreter.addError( + new TemplateError( + TemplateError.ErrorType.FATAL, + TemplateError.ErrorReason.SYNTAX_ERROR, + TemplateError.ErrorItem.TAG, + "Concurrent Modification Error: Cannot modify collection in 'for' loop", + "", + interpreter.getLineNumber(), + interpreter.getPosition(), + e + ) + ); + break; + } // set item variables if (loopVars.size() == 1) { - interpreter.getContext().put(loopVars.get(0), val); - } - else { - for (String loopVar : loopVars) { - if (Map.Entry.class.isAssignableFrom(val.getClass())) { - Map.Entry entry = (Entry) val; + if ( + val == null && + interpreter.getContext().get(loopVars.get(0)) != null && + interpreter.getConfig().getLegacyOverrides().isKeepNullableLoopValues() + ) { + interpreter.getContext().put(loopVars.get(0), NullValue.INSTANCE); + } else { + interpreter.getContext().put(loopVars.get(0), val); + } + } else { + for (int loopVarIndex = 0; loopVarIndex < loopVars.size(); loopVarIndex++) { + String loopVar = loopVars.get(loopVarIndex); + if (val == null) { + if ( + interpreter.getContext().get(loopVar) != null && + interpreter.getConfig().getLegacyOverrides().isKeepNullableLoopValues() + ) { + interpreter.getContext().put(loopVar, NullValue.INSTANCE); + } else { + interpreter.getContext().put(loopVar, null); + } + continue; + } + if (Entry.class.isAssignableFrom(val.getClass())) { + Entry entry = (Entry) val; Object entryVal = null; - if ("key".equals(loopVar)) { + if (loopVars.indexOf(loopVar) == 0) { entryVal = entry.getKey(); - } - else if ("value".equals(loopVar)) { + } else if (loopVars.indexOf(loopVar) == 1) { entryVal = entry.getValue(); } interpreter.getContext().put(loopVar, entryVal); - } - else { + } else if (List.class.isAssignableFrom(val.getClass())) { + List entries = ((PyList) val).toList(); + Object entryVal = null; + // safety check for size + if (entries.size() >= loopVarIndex) { + entryVal = entries.get(loopVarIndex); + } + interpreter.getContext().put(loopVar, entryVal); + } else { try { - PropertyDescriptor[] valProps = Introspector.getBeanInfo(val.getClass()).getPropertyDescriptors(); + PropertyDescriptor[] valProps = Introspector + .getBeanInfo(val.getClass()) + .getPropertyDescriptors(); for (PropertyDescriptor valProp : valProps) { if (loopVar.equals(valProp.getName())) { - interpreter.getContext().put(loopVar, valProp.getReadMethod().invoke(val)); + interpreter + .getContext() + .put(loopVar, interpreter.resolveProperty(val, valProp.getName())); break; } } } catch (Exception e) { - throw new InterpretException(e.getMessage(), e, tagNode.getLineNumber()); + throw new InterpretException( + e.getMessage(), + e, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } } } } for (Node node : tagNode.getChildren()) { - buff.append(node.render(interpreter)); + if (interpreter.getContext().isValidationMode()) { + node.render(interpreter); + } else { + try { + buff.append(node.render(interpreter)); + } catch (OutputTooBigException e) { + if (interpreter.getConfig().getExecutionMode().useEagerParser()) { + throw new DeferredValueException(TOO_LARGE_EXCEPTION_MESSAGE); + } + interpreter.addError(TemplateError.fromOutputTooBigException(e)); + return checkLoopVariable(interpreter, buff); + } + // continue in the body of the loop; ignore the rest of the body + if (loop.isContinued()) { + break; + } + } + } + if ( + interpreter.getConfig().getMaxNumDeferredTokens() < + ((loop.getLength() * interpreter.getContext().getDeferredTokens().size()) / + loop.getIndex()) + ) { + throw new DeferredValueException(TOO_LARGE_EXCEPTION_MESSAGE); } } + return checkLoopVariable(interpreter, buff); + } + } - return buff.toString(); - } finally { - interpreter.leaveScope(); + private String checkLoopVariable( + JinjavaInterpreter interpreter, + LengthLimitingStringBuilder buff + ) { + if (interpreter.getContext().get(LOOP) instanceof DeferredValue) { + throw new DeferredValueException( + "loop variable deferred", + interpreter.getLineNumber(), + interpreter.getPosition() + ); } + return buff.toString(); + } + public Pair, String> getLoopVarsAndExpression(TagToken tagToken) { + List helperTokens = new HelperStringTokenizer(tagToken.getHelpers()) + .splitComma(true) + .allTokens(); + List loopVars = getLoopVars(helperTokens); + Optional maybeLoopExpr = getLoopExpression(tagToken.getHelpers()); + + if (loopVars.size() >= helperTokens.size() || !maybeLoopExpr.isPresent()) { + throw new TemplateSyntaxException( + tagToken.getHelpers().trim(), + "Tag 'for' expects valid 'in' clause, got: " + tagToken.getHelpers(), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + return Pair.of(loopVars, maybeLoopExpr.get()); } - @Override - public String getEndTagName() { - return ENDTAGNAME; + private Optional getLoopExpression(String helpers) { + Matcher matcher = IN_PATTERN.matcher(helpers); + if (matcher.find()) { + return Optional.of(helpers.substring(matcher.end()).trim()); + } + return Optional.empty(); + } + + private List getLoopVars(List helper) { + List loopVars = Lists.newArrayList(); + while (loopVars.size() < helper.size()) { + String val = helper.get(loopVars.size()); + + if ("in".equals(val)) { + break; + } else { + loopVars.add(val); + } + } + return loopVars; } @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/FromTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/FromTag.java index af10c8c1d..0e2e89691 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/FromTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/FromTag.java @@ -1,64 +1,214 @@ package com.hubspot.jinjava.lib.tag; -import java.io.IOException; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterators; import com.google.common.collect.PeekingIterator; +import com.hubspot.algebra.Result; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; import com.hubspot.jinjava.lib.fn.MacroFunction; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; import com.hubspot.jinjava.util.HelperStringTokenizer; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Alternative to the import tag that lets you import and use specific macros from one template to another", - params = { - @JinjavaParam(value = "path", desc = "Design Manager path to file to import from"), - @JinjavaParam(value = "macro_name", desc = "Name of macro or comma separated macros to import (import macro_name)") - }, - snippets = { - @JinjavaSnippet( - desc = "This example uses an html file containing two macros.", - code = "{% macro header(tag, title_text) %}\n" + - "
<{{ tag }}>{{ title_text }}
\n" + - "{% endmacro %}\n" + - "{% macro footer(tag, footer_text) %}\n" + - "
<{{ tag }}>{{ footer_text }}
\n" + - "{% endmacro %}" - ), - @JinjavaSnippet( - desc = "The macro html file is accessed from a different template, but only the footer macro is imported and executed", - code = "{% from 'custom/page/web_page_basic/my_macros.html' import footer %}\n" + - "{{ footer('h2', 'My footer info') }}" - ), - }) + value = "Alternative to the import tag that lets you import and use specific macros from one template to another", + params = { + @JinjavaParam(value = "path", desc = "Design Manager path to file to import from"), + @JinjavaParam( + value = "macro_name", + desc = "Name of macro or comma separated macros to import (import macro_name)" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example uses an html file containing two macros.", + code = "{% macro header(tag, title_text) %}\n" + + "
<{{ tag }}>{{ title_text }}
\n" + + "{% endmacro %}\n" + + "{% macro footer(tag, footer_text) %}\n" + + "
<{{ tag }}>{{ footer_text }}
\n" + + "{% endmacro %}" + ), + @JinjavaSnippet( + desc = "The macro html file is accessed from a different template, but only the footer macro is imported and executed", + code = "{% from 'custom/page/web_page_basic/my_macros.html' import footer %}\n" + + "{{ footer('h2', 'My footer info') }}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% from '${1:path}' import ${2:macro_name} %}") public class FromTag implements Tag { + public static final String TAG_NAME = "from"; + private static final long serialVersionUID = 6152691434172265022L; @Override public String getName() { - return "from"; + return TAG_NAME; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - List helper = new HelperStringTokenizer(tagNode.getHelpers()).splitComma(true).allTokens(); - if (helper.size() < 3 || !helper.get(1).equals("import")) { - throw new InterpretException("Tag 'from' expects import list: " + helper, tagNode.getLineNumber()); + List helper = getHelpers((TagToken) tagNode.getMaster()); + + try ( + AutoCloseableImpl> templateFileResult = + getTemplateFileWithWrapper(helper, (TagToken) tagNode.getMaster(), interpreter) + .get() + ) { + return templateFileResult + .value() + .match( + err -> { + String path = StringUtils.trimToEmpty(helper.get(0)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "From cycle detected for path: '" + path + "'", + null, + ((TagToken) tagNode.getMaster()).getLineNumber(), + ((TagToken) tagNode.getMaster()).getStartPosition(), + err, + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("path", path) + ) + ); + return ""; + }, + templateFile -> { + Map imports = getImportMap(helper); + + try { + String template = interpreter.getResource(templateFile); + Node node = interpreter.parse(template); + + JinjavaInterpreter child = interpreter + .getConfig() + .getInterpreterFactory() + .newInstance(interpreter); + child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, templateFile); + child.render(node); + + interpreter.addAllChildErrors(templateFile, child.getErrorsCopy()); + + boolean importsDeferredValue = integrateChild(imports, child, interpreter); + + if (importsDeferredValue) { + handleDeferredNodesDuringImport( + (TagToken) tagNode.getMaster(), + templateFile, + imports, + child, + interpreter + ); + } + + return ""; + } catch (IOException e) { + throw new InterpretException( + e.getMessage(), + e, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + } + ); + } + } + + public static void handleDeferredNodesDuringImport( + TagToken tagToken, + String templateFile, + Map imports, + JinjavaInterpreter child, + JinjavaInterpreter interpreter + ) { + for (Map.Entry importMapping : imports.entrySet()) { + Object val = child.getContext().getGlobalMacro(importMapping.getKey()); + if (val != null) { + MacroFunction macro = (MacroFunction) val; + macro.setDeferred(true); + interpreter.getContext().addGlobalMacro(macro); + } else { + val = child.getContext().get(importMapping.getKey()); + if (val != null) { + interpreter + .getContext() + .put(importMapping.getValue(), DeferredValue.instance()); + } + } } - String templateFile = interpreter.resolveString(helper.get(0), tagNode.getLineNumber()); + throw new DeferredValueException( + templateFile, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + + public static boolean integrateChild( + Map imports, + JinjavaInterpreter child, + JinjavaInterpreter interpreter + ) { + boolean importsDeferredValue = false; + for (Map.Entry importMapping : imports.entrySet()) { + Object val = child.getContext().getGlobalMacro(importMapping.getKey()); + + if (val != null) { + MacroFunction toImport = (MacroFunction) val; + if (!importMapping.getKey().equals(importMapping.getValue())) { + toImport = toImport.cloneWithNewName(importMapping.getValue()); + } + interpreter.getContext().addGlobalMacro(toImport); + } else { + val = child.getContext().get(importMapping.getKey()); + + if (val != null) { + interpreter.getContext().put(importMapping.getValue(), val); + if (val instanceof DeferredValue) { + importsDeferredValue = true; + } + } + } + } + return importsDeferredValue; + } + + public static Map getImportMap(List helper) { Map imports = new LinkedHashMap<>(); - PeekingIterator args = Iterators.peekingIterator(helper.subList(2, helper.size()).iterator()); + PeekingIterator args = Iterators.peekingIterator( + helper.subList(2, helper.size()).iterator() + ); while (args.hasNext()) { String fromName = args.next(); @@ -71,40 +221,74 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { imports.put(fromName, importName); } + return imports; + } - try { - String template = interpreter.getResource(templateFile); - Node node = interpreter.parse(template); - - JinjavaInterpreter child = new JinjavaInterpreter(interpreter); - child.render(node); - - interpreter.getErrors().addAll(child.getErrors()); - - for (Map.Entry importMapping : imports.entrySet()) { - Object val = child.getContext().getGlobalMacro(importMapping.getKey()); - - if (val != null) { - interpreter.getContext().addGlobalMacro((MacroFunction) val); - } - else { - val = child.getContext().get(importMapping.getKey()); + public static AutoCloseableSupplier> getTemplateFileWithWrapper( + List helper, + TagToken tagToken, + JinjavaInterpreter interpreter + ) { + String templateFile = interpreter.resolveString( + helper.get(0), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + templateFile = interpreter.resolveResourceLocation(templateFile); + interpreter.getContext().addDependency("coded_files", templateFile); + return interpreter + .getContext() + .getFromPathStack() + .closeablePush(templateFile, tagToken.getLineNumber(), tagToken.getStartPosition()); + } - if (val != null) { - interpreter.getContext().put(importMapping.getValue(), val); - } - } - } + @Deprecated + public static Optional getTemplateFile( + List helper, + TagToken tagToken, + JinjavaInterpreter interpreter + ) { + return getTemplateFileWithWrapper(helper, tagToken, interpreter) + .dangerouslyGetWithoutClosing() + .match( + err -> { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "From cycle detected for path: '" + err.getPath() + "'", + null, + tagToken.getLineNumber(), + tagToken.getStartPosition(), + err, + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("path", err.getPath()) + ) + ); + return Optional.empty(); + }, + Optional::of + ); + } - return ""; - } catch (IOException e) { - throw new InterpretException(e.getMessage(), e, tagNode.getLineNumber()); + public static List getHelpers(TagToken tagToken) { + List helper = new HelperStringTokenizer(tagToken.getHelpers()) + .splitComma(true) + .allTokens(); + if (helper.size() < 3 || !helper.get(1).equals("import")) { + throw new TemplateSyntaxException( + tagToken.getImage(), + "Tag 'from' expects import list: " + helper, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); } + return helper; } @Override public String getEndTagName() { return null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/IfTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/IfTag.java index cb712afb3..85040036d 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/IfTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/IfTag.java @@ -1,114 +1,148 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.util.Iterator; - -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; import com.hubspot.jinjava.util.ObjectTruthValue; +import java.util.Iterator; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Outputs inner content if expression evaluates to true, otherwise evaluates any elif blocks, finally outputting content of any else block present", - snippets = { - @JinjavaSnippet( - code = "{% if condition %}\n" + - "If the condition is true print this to template.\n" + - "{% endif %}" - ), - @JinjavaSnippet( - code = "{% if number <= 2 %}\n" + - "Varible named number is less than or equal to 2.\n" + - "{% elif number <= 4 %}\n" + - "Varible named number is less than or equal to 4.\n" + - "{% elif number <= 6 %}\n" + - "Varible named number is less than or equal to 6.\n" + - "{% else %}\n" + - "Varible named number is greater than 6.\n" + - "{% endif %}") - }) + value = "Outputs inner content if expression evaluates to true, otherwise evaluates any elif blocks, finally outputting content of any else block present", + params = { + @JinjavaParam( + value = "condition", + type = "conditional expression", + desc = "An expression that evaluates to either true or false" + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if condition %}\n" + + "If the condition is true print this to template.\n" + + "{% endif %}" + ), + @JinjavaSnippet( + code = "{% if number <= 2 %}\n" + + "Variable named number is less than or equal to 2.\n" + + "{% elif number <= 4 %}\n" + + "Variable named number is less than or equal to 4.\n" + + "{% elif number <= 6 %}\n" + + "Variable named number is less than or equal to 6.\n" + + "{% else %}\n" + + "Variable named number is greater than 6.\n" + + "{% endif %}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% if '${1:condition}' %}\n\n{% endif %}") +@JinjavaHasCodeBody public class IfTag implements Tag { + public static final String TAG_NAME = "if"; + private static final long serialVersionUID = -3784039314941268904L; - private static final String TAGNAME = "if"; - private static final String ENDTAGNAME = "endif"; + + @Override + public boolean isRenderedInValidationMode() { + return true; + } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { if (StringUtils.isBlank(tagNode.getHelpers())) { - throw new InterpretException("Tag 'if' expects expression", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'if' expects expression", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } + LengthLimitingStringBuilder sb = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + Iterator nodeIterator = tagNode.getChildren().iterator(); - TagNode nextIfElseTagNode = tagNode; - while (nextIfElseTagNode != null && !evaluateIfElseTagNode(nextIfElseTagNode, interpreter)) { - nextIfElseTagNode = findNextIfElseTagNode(nodeIterator); - } + boolean parentValidationMode = interpreter.getContext().isValidationMode(); + + boolean execute = isPositiveIfElseNode(tagNode, interpreter); + boolean executedAnyBlock = false; - StringBuilder sb = new StringBuilder(); - if (nextIfElseTagNode != null) { + try { while (nodeIterator.hasNext()) { - Node n = nodeIterator.next(); - if (n.getName().equals(ElseIfTag.ELSEIF) || n.getName().equals(ElseTag.ELSE)) { - break; + executedAnyBlock = executedAnyBlock || execute; + if (interpreter.isValidationMode() && !parentValidationMode) { + interpreter.getContext().setValidationMode(!execute); } - sb.append(n.render(interpreter)); - } - } - return sb.toString(); - } + Node node = nodeIterator.next(); + if (TagNode.class.isAssignableFrom(node.getClass())) { + TagNode tag = (TagNode) node; + if (tag.getName().equals(ElseIfTag.TAG_NAME)) { + execute = !executedAnyBlock && isPositiveIfElseNode(tag, interpreter); + continue; + } else if (tag.getName().equals(ElseTag.TAG_NAME)) { + execute = !executedAnyBlock; + continue; + } + } - private TagNode findNextIfElseTagNode(Iterator nodeIterator) { - while (nodeIterator.hasNext()) { - Node node = nodeIterator.next(); - if (TagNode.class.isAssignableFrom(node.getClass())) { - TagNode tag = (TagNode) node; - if (tag.getName().equals(ElseIfTag.ELSEIF) || tag.getName().equals(ElseTag.ELSE)) { - return tag; + if (execute) { + try { + sb.append(node.render(interpreter)); + } catch (OutputTooBigException e) { + interpreter.addError(TemplateError.fromOutputTooBigException(e)); + return sb.toString(); + } + } else if (interpreter.getContext().isValidationMode()) { + node.render(interpreter); } } + } finally { + interpreter.getContext().setValidationMode(parentValidationMode); } - return null; - } - - protected boolean evaluateIfElseTagNode(TagNode tagNode, JinjavaInterpreter interpreter) { - if (tagNode.getName().equals(ElseTag.ELSE)) { - return true; - } - - return ObjectTruthValue.evaluate(interpreter.resolveELExpression(tagNode.getHelpers(), tagNode.getLineNumber())); + return sb.toString(); } - @Override - public String getEndTagName() { - return ENDTAGNAME; + public boolean isPositiveIfElseNode(TagNode tagNode, JinjavaInterpreter interpreter) { + return ObjectTruthValue.evaluate( + interpreter.resolveELExpression( + tagNode.getHelpers(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ) + ); } @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/IfchangedTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/IfchangedTag.java index 79547ea7f..b2e1e996f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/IfchangedTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/IfchangedTag.java @@ -1,54 +1,66 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Outputs the tag contents if the given variable has changed since a prior invocation of this tag", - hidden = true, - snippets = { - @JinjavaSnippet( - code = "{% ifchanged var %}\n" + - "Variable to test if changed\n" + - "{% endifchanged %}") - }) + value = "Outputs the tag contents if the given variable has changed since a prior invocation of this tag", + hidden = true, + snippets = { + @JinjavaSnippet( + code = "{% ifchanged var %}\n" + + "Variable to test if changed\n" + + "{% endifchanged %}" + ), + } +) +@JinjavaHasCodeBody public class IfchangedTag implements Tag { + public static final String TAG_NAME = "ifchanged"; + private static final long serialVersionUID = 3567908136629704724L; private static final String LASTKEY = "'IF\"CHG"; - private static final String TAGNAME = "ifchanged"; - private static final String ENDTAGNAME = "endifchanged"; @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { if (StringUtils.isBlank(tagNode.getHelpers())) { - throw new InterpretException("Tag 'ifchanged' expects a variable parameter", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'ifchanged' expects a variable parameter", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } boolean isChanged = true; String var = tagNode.getHelpers().trim(); Object older = interpreter.getContext().get(LASTKEY + var); - Object test = interpreter.retraceVariable(var, tagNode.getLineNumber()); + Object test = interpreter.retraceVariable( + var, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); if (older == null) { if (test == null) { isChanged = false; @@ -67,14 +79,8 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { return ""; } - @Override - public String getEndTagName() { - return ENDTAGNAME; - } - @Override public String getName() { - return TAGNAME; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ImportTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ImportTag.java index 545570a39..8fbc7a83a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ImportTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ImportTag.java @@ -1,25 +1,36 @@ package com.hubspot.jinjava.lib.tag; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - -import java.io.IOException; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.apache.commons.lang3.StringUtils; - +import com.google.common.collect.ImmutableMap; +import com.hubspot.algebra.Result; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; import com.hubspot.jinjava.lib.fn.MacroFunction; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; import com.hubspot.jinjava.util.HelperStringTokenizer; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; /** * Jinja2 supports putting often used code into macros. These macros can go into different templates and get imported from there. This works similar to the import statements in Python. It’s important to know that imports are cached and @@ -29,112 +40,280 @@ */ @JinjavaDoc( - value = "Allows you to access and use macros from a different template", - params = { - @JinjavaParam(value = "path", desc = "Design Manager path to file to import"), - @JinjavaParam(value = "import_name", desc = "Give a name to the imported file to access macros from") - }, - snippets = { - @JinjavaSnippet( - desc = "This example uses an html file containing two macros.", - code = "{% macro header(tag, title_text) %}\n" + - "
<{{ tag }}>{{ title_text }}
\n" + - "{% endmacro %}\n" + - "{% macro footer(tag, footer_text) %}\n" + - "
<{{ tag }}>{{ footer_text }}
\n" + - "{% endmacro %}" - ), - @JinjavaSnippet( - desc = "The macro html file is imported from a different template. Macros are then accessed from the name given to the import.", - code = "{% import 'custom/page/web_page_basic/my_macros.html' as header_footer %}\n" + - "{{ header_footer.header('h1', 'My page title') }}\n" + - "{{ header_footer.footer('h3', 'Company footer info') }}" - ) - }) -@SuppressWarnings("unchecked") + value = "Allows you to access and use macros from a different template", + params = { + @JinjavaParam(value = "path", desc = "Design Manager path to file to import"), + @JinjavaParam( + value = "import_name", + desc = "Give a name to the imported file to access macros from" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "This example uses an html file containing two macros.", + code = "{% macro header(tag, title_text) %}\n" + + "
<{{ tag }}>{{ title_text }}
\n" + + "{% endmacro %}\n" + + "{% macro footer(tag, footer_text) %}\n" + + "
<{{ tag }}>{{ footer_text }}
\n" + + "{% endmacro %}" + ), + @JinjavaSnippet( + desc = "The macro html file is imported from a different template. Macros are then accessed from the name given to the import.", + code = "{% import 'custom/page/web_page_basic/my_macros.html' as header_footer %}\n" + + "{{ header_footer.header('h1', 'My page title') }}\n" + + "{{ header_footer.footer('h3', 'Company footer info') }}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% import '${1:path}' ${2: as ${3:import_name}} %}") public class ImportTag implements Tag { + + public static final String TAG_NAME = "import"; + private static final long serialVersionUID = 8433638845398005260L; - private static final String IMPORT_PATH_PROPERTY = "__importP@th__"; @Override public String getName() { - return "import"; + return TAG_NAME; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - List helper = new HelperStringTokenizer(tagNode.getHelpers()).allTokens(); - if (helper.isEmpty()) { - throw new InterpretException("Tag 'import' expects 1 helper >>> " + helper.size(), tagNode.getLineNumber()); - } + List helper = getHelpers((TagToken) tagNode.getMaster()); - String contextVar = ""; + String contextVar = getContextVar(helper); - if (helper.size() > 2 && "as".equals(helper.get(1))) { - contextVar = helper.get(2); - } + try ( + AutoCloseableImpl> templateFileResult = + getTemplateFileWithWrapper(helper, (TagToken) tagNode.getMaster(), interpreter) + .get() + ) { + return templateFileResult + .value() + .match( + err -> { + String path = StringUtils.trimToEmpty(helper.get(0)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Import cycle detected for path: '" + path + "'", + null, + ((TagToken) tagNode.getMaster()).getLineNumber(), + ((TagToken) tagNode.getMaster()).getStartPosition(), + err, + BasicTemplateErrorCategory.IMPORT_CYCLE_DETECTED, + ImmutableMap.of("path", path) + ) + ); + return ""; + }, + templateFile -> { + try ( + AutoCloseableImpl node = parseTemplateAsNode( + interpreter, + templateFile + ) + .get() + ) { + JinjavaInterpreter child = interpreter + .getConfig() + .getInterpreterFactory() + .newInstance(interpreter); + child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, templateFile); + child.render(node.value()); - String path = StringUtils.trimToEmpty(helper.get(0)); - if (isPathInRenderStack(interpreter.getContext(), path)) { - ENGINE_LOG.debug("Path {} is already in include stack", path); - return ""; - } + interpreter.addAllChildErrors(templateFile, child.getErrorsCopy()); - Set importedPaths = (Set) interpreter.getContext().get(IMPORT_PATH_PROPERTY); - if (importedPaths == null) { - importedPaths = new HashSet(); - interpreter.getContext().put(IMPORT_PATH_PROPERTY, importedPaths); - } - importedPaths.add(path); + Map childBindings = child.getContext().getSessionBindings(); - String templateFile = interpreter.resolveString(path, tagNode.getLineNumber()); - try { - String template = interpreter.getResource(templateFile); - Node node = interpreter.parse(template); + // If the template depends on deferred values it should not be rendered and all defined variables and macros should be deferred too + if (!child.getContext().getDeferredNodes().isEmpty()) { + handleDeferredNodesDuringImport( + node.value(), + contextVar, + childBindings, + child, + interpreter + ); + throw new DeferredValueException( + templateFile, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } - if (StringUtils.isBlank(contextVar)) { - interpreter.render(node); - } - else { - JinjavaInterpreter child = new JinjavaInterpreter(interpreter); - child.render(node); + integrateChild(contextVar, childBindings, child, interpreter); + return ""; + } catch (IOException e) { + throw new InterpretException( + e.getMessage(), + e, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + } + ); + } + } - interpreter.getErrors().addAll(child.getErrors()); + public static void integrateChild( + String contextVar, + Map childBindings, + JinjavaInterpreter child, + JinjavaInterpreter parent + ) { + if (StringUtils.isBlank(contextVar)) { + for (MacroFunction macro : child.getContext().getGlobalMacros().values()) { + parent.getContext().addGlobalMacro(macro); + } + childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + parent + .getContext() + .putAll(getChildBindingsWithoutImportResourcePath(childBindings)); + } else { + childBindings.putAll(child.getContext().getGlobalMacros()); + childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + parent.getContext().put(contextVar, childBindings); + } + } - Map childBindings = child.getContext().getSessionBindings(); - for (Map.Entry macro : child.getContext().getGlobalMacros().entrySet()) { - childBindings.put(macro.getKey(), macro.getValue()); - } - childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + public static Map getChildBindingsWithoutImportResourcePath( + Map childBindings + ) { + Map filteredMap = new HashMap<>(); + // Don't remove them from childBindings, because it is needed in a macro function's localContextScope + childBindings + .entrySet() + .stream() + .filter(entry -> !entry.getKey().equals(Context.IMPORT_RESOURCE_PATH_KEY)) + .forEach(entry -> filteredMap.put(entry.getKey(), entry.getValue())); + return filteredMap; + } - interpreter.getContext().put(contextVar, childBindings); + public static void handleDeferredNodesDuringImport( + Node node, + String contextVar, + Map childBindings, + JinjavaInterpreter child, + JinjavaInterpreter interpreter + ) { + node + .getChildren() + .forEach(deferredChild -> interpreter.getContext().handleDeferredNode(deferredChild) + ); + if (StringUtils.isBlank(contextVar)) { + for (MacroFunction macro : child.getContext().getGlobalMacros().values()) { + macro.setDeferred(true); + interpreter.getContext().addGlobalMacro(macro); } - - return ""; - } catch (IOException e) { - throw new InterpretException(e.getMessage(), e, tagNode.getLineNumber()); + childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + childBindings + .keySet() + .forEach(key -> interpreter.getContext().put(key, DeferredValue.instance())); + } else { + for (Map.Entry macroEntry : child + .getContext() + .getGlobalMacros() + .entrySet()) { + MacroFunction macro = macroEntry.getValue(); + macro.setDeferred(true); + childBindings.put(macroEntry.getKey(), macro); + } + childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + interpreter.getContext().put(contextVar, DeferredValue.instance(childBindings)); } } - private boolean isPathInRenderStack(Context context, String path) { - Context current = context; - do { - Set importedPaths = (Set) current.get(IMPORT_PATH_PROPERTY, new HashSet()); + public static AutoCloseableSupplier parseTemplateAsNode( + JinjavaInterpreter interpreter, + String templateFile + ) throws IOException { + String template = interpreter.getResource(templateFile); + return interpreter + .getContext() + .getCurrentPathStack() + .closeablePush(templateFile, interpreter.getLineNumber(), interpreter.getPosition()) + .map(currentPath -> interpreter.parse(template)); + } - if (importedPaths.contains(path)) { - return true; - } + public static AutoCloseableSupplier> getTemplateFileWithWrapper( + List helper, + TagToken tagToken, + JinjavaInterpreter interpreter + ) { + String path = StringUtils.trimToEmpty(helper.get(0)); + String templateFile = interpreter.resolveString( + path, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + templateFile = interpreter.resolveResourceLocation(templateFile); + interpreter.getContext().addDependency("coded_files", templateFile); + return interpreter + .getContext() + .getImportPathStack() + .closeablePush(templateFile, tagToken.getLineNumber(), tagToken.getStartPosition()); + } - current = current.getParent(); + @Deprecated + public static Optional getTemplateFile( + List helper, + TagToken tagToken, + JinjavaInterpreter interpreter + ) { + return getTemplateFileWithWrapper(helper, tagToken, interpreter) + .dangerouslyGetWithoutClosing() + .match( + err -> { + String path = StringUtils.trimToEmpty(helper.get(0)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Import cycle detected for path: '" + path + "'", + null, + tagToken.getLineNumber(), + tagToken.getStartPosition(), + err, + BasicTemplateErrorCategory.IMPORT_CYCLE_DETECTED, + ImmutableMap.of("path", path) + ) + ); + return Optional.empty(); + }, + ok -> Optional.of(ok) + ); + } - } while (current != null); + public static String getContextVar(List helper) { + String contextVar = ""; - return false; + if (helper.size() > 2 && "as".equals(helper.get(1))) { + contextVar = helper.get(2); + } + return contextVar; + } + + public static List getHelpers(TagToken tagToken) { + List helper = new HelperStringTokenizer(tagToken.getHelpers()).allTokens(); + if (helper.isEmpty()) { + throw new TemplateSyntaxException( + tagToken.getImage(), + "Tag 'import' expects 1 helper, was: " + helper.size(), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + return helper; } @Override public String getEndTagName() { return null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/IncludeTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/IncludeTag.java index b2e911bd6..cdebc0041 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/IncludeTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/IncludeTag.java @@ -1,97 +1,174 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - -import java.io.IOException; - -import org.apache.commons.lang3.StringUtils; - +import com.google.common.collect.ImmutableMap; +import com.hubspot.algebra.Result; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.util.HelperStringTokenizer; +import java.io.IOException; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "includes multiple files in one template or stylesheet", - params = { - @JinjavaParam(value = "path", desc = "Design Manager path to the file that you would like to include") - }, - snippets = { - @JinjavaSnippet(code = "{% include \"custom/page/web_page_basic/my_footer.html\" %}"), - @JinjavaSnippet(code = "{% include \"generated_global_groups/2781996615.html\" %}"), - @JinjavaSnippet(code = "{% include \"hubspot/styles/patches/recommended.css\" %}") - }) + value = "includes multiple files in one template or stylesheet", + params = { + @JinjavaParam( + value = "path", + desc = "Design Manager path to the file that you would like to include" + ), + }, + snippets = { + @JinjavaSnippet(code = "{% include \"custom/page/web_page_basic/my_footer.html\" %}"), + @JinjavaSnippet(code = "{% include \"generated_global_groups/2781996615.html\" %}"), + @JinjavaSnippet(code = "{% include \"hubspot/styles/patches/recommended.css\" %}"), + } +) +@JinjavaTextMateSnippet(code = "{% include '${1:path}' %}") public class IncludeTag implements Tag { + + public static final String TAG_NAME = "include"; + private static final long serialVersionUID = -8391753639874726854L; - private static final String INCLUDE_PATH_PROPERTY = "__includeP@th__"; @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + final String finalTemplateFile = resolveTemplateFile(tagNode, interpreter); + final TagNode finalTagNode = tagNode; + try ( + AutoCloseableImpl> includeStackWrapper = + interpreter + .getContext() + .getIncludePathStack() + .closeablePush( + finalTemplateFile, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ) + .get(); + AutoCloseableImpl> currentPathWrapper = + interpreter + .getContext() + .getCurrentPathStack() + .closeablePush( + finalTemplateFile, + interpreter.getLineNumber(), + interpreter.getPosition() + ) + .get() + ) { + return includeStackWrapper + .value() + .match( + err -> { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Include cycle detected for path: '" + finalTemplateFile + "'", + null, + finalTagNode.getLineNumber(), + finalTagNode.getStartPosition(), + err, + BasicTemplateErrorCategory.INCLUDE_CYCLE_DETECTED, + ImmutableMap.of("path", finalTemplateFile) + ) + ); + return ""; + }, + includeStackPath -> + currentPathWrapper + .value() + .match( + err -> { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Include cycle detected for path: '" + finalTemplateFile + "'", + null, + finalTagNode.getLineNumber(), + finalTagNode.getStartPosition(), + err, + BasicTemplateErrorCategory.INCLUDE_CYCLE_DETECTED, + ImmutableMap.of("path", finalTemplateFile) + ) + ); + return ""; + }, + currentPath -> { + try { + String template = interpreter.getResource(finalTemplateFile); + Node node = interpreter.parse(template); + interpreter + .getContext() + .addDependency("coded_files", finalTemplateFile); + return interpreter.render(node, false); + } catch (IOException e) { + throw new InterpretException( + e.getMessage(), + e, + finalTagNode.getLineNumber(), + finalTagNode.getStartPosition() + ); + } + } + ) + ); + } + } + + public static String resolveTemplateFile( + TagNode tagNode, + JinjavaInterpreter interpreter + ) { HelperStringTokenizer helper = new HelperStringTokenizer(tagNode.getHelpers()); if (!helper.hasNext()) { - throw new InterpretException("Tag 'include' expects template path", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'include' expects template path", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } String path = StringUtils.trimToEmpty(helper.next()); - - if (isPathInRenderStack(interpreter.getContext(), path)) { - ENGINE_LOG.debug("Path {} is already in include stack", path); - return ""; - } - - String templateFile = interpreter.resolveString(path, tagNode.getLineNumber()); - try { - String template = interpreter.getResource(templateFile); - Node node = interpreter.parse(template); - JinjavaInterpreter child = new JinjavaInterpreter(interpreter); - child.getContext().addDependency("coded_files", templateFile); - child.getContext().put(INCLUDE_PATH_PROPERTY, path); - interpreter.getContext().put(JinjavaInterpreter.INSERT_FLAG, true); - - String result = child.render(node); - interpreter.getErrors().addAll(child.getErrors()); - - return result; - - } catch (IOException e) { - throw new InterpretException(e.getMessage(), e, tagNode.getLineNumber()); - } - } - - private boolean isPathInRenderStack(Context context, String path) { - Context current = context; - do { - String includePath = (String) current.get(INCLUDE_PATH_PROPERTY); - - if (StringUtils.equals(path, includePath)) { - return true; - } - - current = current.getParent(); - - } while (current != null); - - return false; + String templateFile = interpreter.resolveString( + path, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + templateFile = interpreter.resolveResourceLocation(templateFile); + return templateFile; } @Override @@ -101,7 +178,6 @@ public String getEndTagName() { @Override public String getName() { - return "include"; + return TAG_NAME; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/MacroTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/MacroTag.java index 2d3cee771..fd52f6c5a 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/MacroTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/MacroTag.java @@ -1,113 +1,255 @@ package com.hubspot.jinjava.lib.tag; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.apache.commons.lang3.StringUtils; - -import com.google.common.base.Objects; import com.google.common.base.Splitter; +import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.lib.fn.MacroFunction; import com.hubspot.jinjava.tree.TagNode; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "HubL macros allow you to print multiple statements with a dynamic value or values", - params = { - @JinjavaParam(value = "macro_name", desc = "The name given to a macro"), - @JinjavaParam(value = "argument_names", desc = "Named arguments that are dynamically, when the macro is run") - }, - snippets = { - @JinjavaSnippet( - desc = "Basic macro syntax", - code = "{% macro name_of_macro(argument_name, argument_name2) %}\n" + - " {{ argument_name }}\n" + - " {{ argument_name2 }}\n" + - "{% endmacro %}\n" + - "{{ name_of_macro(\"value to pass to argument 1\", \"value to pass to argument 2\") }}" - ), - @JinjavaSnippet( - desc = "Example of a macro used to print CSS3 properties with the various vendor prefixes", - code = "{% macro trans(value) %}\n" + - " -webkit-transition: {{value}};\n" + - " -moz-transition: {{value}};\n" + - " -o-transition: {{value}};\n" + - " -ms-transition: {{value}};\n" + - " transition: {{value}};\n" + - "{% endmacro %}" - ), - @JinjavaSnippet( - desc = "The macro can then be called like a function. The macro is printed for anchor tags in CSS.", - code = "a { {{ trans(\"all .2s ease-in-out\") }} }"), - }) + value = "Macros allow you to print multiple statements with a dynamic value or values", + params = { + @JinjavaParam(value = "macro_name", desc = "The name given to a macro"), + @JinjavaParam( + value = "argument_names", + desc = "Named arguments that are dynamically, when the macro is run" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Basic macro syntax", + code = "{% macro name_of_macro(argument_name, argument_name2) %}\n" + + " {{ argument_name }}\n" + + " {{ argument_name2 }}\n" + + "{% endmacro %}\n" + + "{{ name_of_macro(\"value to pass to argument 1\", \"value to pass to argument 2\") }}" + ), + @JinjavaSnippet( + desc = "Example of a macro used to print CSS3 properties with the various vendor prefixes", + code = "{% macro trans(value) %}\n" + + " -webkit-transition: {{value}};\n" + + " -moz-transition: {{value}};\n" + + " -o-transition: {{value}};\n" + + " -ms-transition: {{value}};\n" + + " transition: {{value}};\n" + + "{% endmacro %}" + ), + @JinjavaSnippet( + desc = "The macro can then be called like a function. The macro is printed for anchor tags in CSS.", + code = "a { {{ trans(\"all .2s ease-in-out\") }} }" + ), + } +) +@JinjavaHasCodeBody +@JinjavaTextMateSnippet(code = "{% macro ${1:name}(${2:values) %}\n\t$0\n{% endmacro %}") public class MacroTag implements Tag { + public static final String TAG_NAME = "macro"; + private static final long serialVersionUID = 8397609322126956077L; + public static final Pattern CHILD_MACRO_PATTERN = Pattern.compile( + "([a-zA-Z_][\\w_]*)\\.([a-zA-Z_][\\w_]*)[^(]*\\(([^)]*)\\)" + ); + + public static final Pattern MACRO_PATTERN = Pattern.compile( + "([a-zA-Z_][\\w_]*)[^(]*\\(([^)]*)\\)" + ); + private static final Splitter ARGS_SPLITTER = Splitter + .on(',') + .omitEmptyStrings() + .trimResults(); + @Override public String getName() { - return "macro"; + return TAG_NAME; } @Override - public String getEndTagName() { - return "endmacro"; + public boolean isRenderedInValidationMode() { + return true; } @Override + @SuppressWarnings("unchecked") public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - Matcher matcher = MACRO_PATTERN.matcher(tagNode.getHelpers()); - if (!matcher.find()) { - throw new InterpretException("Unable to parse macro definition: " + tagNode.getHelpers()); - } + String name; + String args; + String parentName = ""; + Matcher childMatcher = CHILD_MACRO_PATTERN.matcher(tagNode.getHelpers()); + if (childMatcher.find()) { + parentName = childMatcher.group(1); + name = childMatcher.group(2); + args = Strings.nullToEmpty(childMatcher.group(3)); + } else { + Matcher matcher = MACRO_PATTERN.matcher(tagNode.getHelpers()); + if (!matcher.find()) { + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Unable to parse macro definition: " + tagNode.getHelpers(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } - String name = matcher.group(1); - String args = Objects.firstNonNull(matcher.group(2), ""); + name = matcher.group(1); + args = Strings.nullToEmpty(matcher.group(2)); + } LinkedHashMap argNamesWithDefaults = new LinkedHashMap<>(); + boolean deferred = populateArgNames( + tagNode.getLineNumber(), + interpreter, + args, + argNamesWithDefaults + ); + MacroFunction macro; + Object contextImportResourcePath = interpreter + .getContext() + .get(Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY); + if (contextImportResourcePath instanceof DeferredValue) { + contextImportResourcePath = + ((DeferredValue) contextImportResourcePath).getOriginalValue(); + } + boolean scopeEntered = false; + try { + if (StringUtils.isNotEmpty((String) contextImportResourcePath)) { + scopeEntered = true; + interpreter.enterScope(); + interpreter + .getContext() + .put(Context.IMPORT_RESOURCE_PATH_KEY, contextImportResourcePath); + } + macro = constructMacroFunction(tagNode, interpreter, name, argNamesWithDefaults); + } finally { + if (scopeEntered) { + interpreter.leaveScope(); + } + } + macro.setDeferred(deferred); + + if (StringUtils.isNotEmpty(parentName)) { + try { + Map macroOfParent; + if (!(interpreter.getContext().get(parentName) instanceof DeferredValue)) { + macroOfParent = + (Map) interpreter + .getContext() + .getOrDefault(parentName, new HashMap<>()); + macroOfParent.put(macro.getName(), macro); + if (!interpreter.getContext().containsKey(parentName)) { + interpreter.getContext().put(parentName, macroOfParent); + } + } else { + Object originalValue = + ((DeferredValue) interpreter.getContext().get(parentName)).getOriginalValue(); + if (originalValue instanceof Map) { + ((Map) originalValue).put(macro.getName(), macro); + } else { + macroOfParent = new HashMap<>(); + macroOfParent.put(macro.getName(), macro); + interpreter + .getContext() + .put(parentName, DeferredValue.instance(macroOfParent)); + } + } + } catch (ClassCastException e) { + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Unable to parse macro as a child of: " + parentName, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + } else { + interpreter.getContext().addGlobalMacro(macro); + } + + if (deferred) { + throw new DeferredValueException( + name, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + + return ""; + } + + protected MacroFunction constructMacroFunction( + TagNode tagNode, + JinjavaInterpreter interpreter, + String name, + LinkedHashMap argNamesWithDefaults + ) { + return new MacroFunction( + tagNode.getChildren(), + name, + argNamesWithDefaults, + false, + interpreter.getContext(), + interpreter.getLineNumber(), + interpreter.getPosition() + ); + } + + public static boolean populateArgNames( + int lineNumber, + JinjavaInterpreter interpreter, + String args, + LinkedHashMap argNamesWithDefaults + ) { List argList = Lists.newArrayList(ARGS_SPLITTER.split(args)); + boolean deferred = false; for (int i = 0; i < argList.size(); i++) { String arg = argList.get(i); if (arg.contains("=")) { String argName = StringUtils.substringBefore(arg, "=").trim(); - StringBuilder argValStr = new StringBuilder(StringUtils.substringAfter(arg, "=").trim()); + StringBuilder argValStr = new StringBuilder( + StringUtils.substringAfter(arg, "=").trim() + ); - if (StringUtils.startsWith(argValStr, "[") && !StringUtils.endsWith(argValStr, "]")) { + if ( + StringUtils.startsWith(argValStr, "[") && !StringUtils.endsWith(argValStr, "]") + ) { while (i + 1 < argList.size() && !StringUtils.endsWith(argValStr, "]")) { argValStr.append(", ").append(argList.get(i + 1)); i++; } } - Object argVal = interpreter.resolveELExpression(argValStr.toString(), tagNode.getLineNumber()); - argNamesWithDefaults.put(argName, argVal); - } - else { + try { + Object argVal = interpreter.resolveELExpression( + argValStr.toString(), + lineNumber + ); + argNamesWithDefaults.put(argName, argVal); + } catch (DeferredValueException e) { + deferred = true; + } + } else { argNamesWithDefaults.put(arg, null); } } - - boolean catchKwargs = false; - boolean catchVarargs = false; - boolean caller = false; - - MacroFunction macro = new MacroFunction(tagNode.getChildren(), name, argNamesWithDefaults, - catchKwargs, catchVarargs, caller, interpreter.getContext()); - interpreter.getContext().addGlobalMacro(macro); - - return ""; + return deferred; } - - private static final Pattern MACRO_PATTERN = Pattern.compile("([a-zA-Z_][\\w_]*)[^\\(]*\\(([^\\)]*)\\)"); - private static final Splitter ARGS_SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults(); - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/PrintTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/PrintTag.java index ecc9a2f05..8d1e7ef55 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/PrintTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/PrintTag.java @@ -1,39 +1,46 @@ package com.hubspot.jinjava.lib.tag; -import java.util.Objects; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.tree.TagNode; +import java.util.Objects; @JinjavaDoc( - value = "Echos the result of the expression", - params = @JinjavaParam(value = "expr", type = "expression", desc = "Expression to print"), - snippets = { - @JinjavaSnippet( - code = "{% set string_to_echo = \"Print me\" %}\n" + - "{% print string_to_echo %}") - }) + value = "Echos the result of the expression", + params = @JinjavaParam( + value = "expr", + type = "expression", + desc = "Expression to print" + ), + snippets = { + @JinjavaSnippet( + code = "{% set string_to_echo = \"Print me\" %}\n" + "{% print string_to_echo %}" + ), + } +) public class PrintTag implements Tag { + public static final String TAG_NAME = "print"; + private static final long serialVersionUID = -8613906103187594569L; @Override public String getName() { - return "print"; + return TAG_NAME; } @Override - public String interpret(TagNode tagNode, - JinjavaInterpreter interpreter) { - return Objects.toString(interpreter.resolveELExpression(tagNode.getHelpers(), tagNode.getLineNumber()), ""); + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + return Objects.toString( + interpreter.resolveELExpression(tagNode.getHelpers(), tagNode.getLineNumber()), + "" + ); } @Override public String getEndTagName() { return null; } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/RawTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/RawTag.java index af5d25f1d..7b06e3e8b 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/RawTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/RawTag.java @@ -1,47 +1,73 @@ package com.hubspot.jinjava.lib.tag; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import org.apache.commons.lang3.StringUtils; @JinjavaDoc( - value = "Process all inner HubL as plain text", - snippets = { - @JinjavaSnippet( - code = "{% raw %}\n" + - " The personalization token for a contact's first name is {{ contact.firstname }}\n" + - "{% endraw %}" - ), - }) + value = "Process all inner expressions as plain text", + snippets = { + @JinjavaSnippet( + code = "{% raw %}\n" + + " The personalization token for a contact's first name is {{ contact.firstname }}\n" + + "{% endraw %}" + ), + } +) public class RawTag implements Tag { + + public static final String TAG_NAME = "raw"; + private static final long serialVersionUID = -6963360187396753883L; @Override public String getName() { - return "raw"; - } - - @Override - public String getEndTagName() { - return "endraw"; + return TAG_NAME; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { - StringBuilder result = new StringBuilder(); + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + if ( + interpreter.getConfig().getExecutionMode().isPreserveRawTags() && + !interpreter.getContext().isUnwrapRawOverride() + ) { + result.append( + String.format( + "%s raw %s", + tagNode.getSymbols().getExpressionStartWithTag(), + tagNode.getSymbols().getExpressionEndWithTag() + ) + ); + } for (Node n : tagNode.getChildren()) { result.append(renderNodeRaw(n)); } + if ( + interpreter.getConfig().getExecutionMode().isPreserveRawTags() && + !interpreter.getContext().isUnwrapRawOverride() + ) { + result.append( + String.format( + "%s endraw %s", + tagNode.getSymbols().getExpressionStartWithTag(), + tagNode.getSymbols().getExpressionEndWithTag() + ) + ); + } + return result.toString(); } - private String renderNodeRaw(Node n) { + public String renderNodeRaw(Node n) { StringBuilder result = new StringBuilder(n.getMaster().getImage()); for (Node child : n.getChildren()) { @@ -51,11 +77,17 @@ private String renderNodeRaw(Node n) { if (TagNode.class.isAssignableFrom(n.getClass())) { TagNode t = (TagNode) n; if (StringUtils.isNotBlank(t.getEndName())) { - result.append("{% ").append(t.getEndName()).append(" %}"); + result.append( + String.format( + "%s %s %s", + t.getSymbols().getExpressionStartWithTag(), + t.getEndName(), + t.getSymbols().getExpressionEndWithTag() + ) + ); } } return result.toString(); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/SetTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/SetTag.java index 1daa0cd98..e686b4537 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/SetTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/SetTag.java @@ -1,28 +1,37 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; -import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.DeferredValueUtils; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.StringUtils; /** * {% set primary_line_height = primary_font_size_num*1.5 %} @@ -32,60 +41,245 @@ * @author anysome * */ -@JinjavaDoc(value = "Assigns the value or result of a statement to a variable", - params = { - @JinjavaParam(value = "var", type = "variable identifier", desc = "The name of the variable"), - @JinjavaParam(value = "expr", type = "expression", desc = "The value stored in the variable (string, number, boolean, or sequence") - }, - snippets = { - @JinjavaSnippet( - desc = "Set a variable in with a set statement and print the variable in a expression", - code = "{% set primaryColor = \"#F7761F\" %}\n" + - "{{ primaryColor }}\n" - ), - @JinjavaSnippet( - desc = "You can combine multiple values or variables into a sequence variable", - code = "{% set var_one = \"String 1\" %}\n" + - "{% set var_two = \"String 2\" %}\n" + - "{% set sequence = [var_one, var_two] %}" - ), - }) -public class SetTag implements Tag { +@JinjavaDoc( + value = "Assigns the value or result of a statement to a variable", + params = { + @JinjavaParam( + value = "var", + type = "variable identifier", + desc = "The name of the variable" + ), + @JinjavaParam( + value = "expr", + type = "expression", + desc = "The value stored in the variable (string, number, boolean, or sequence" + ), + }, + snippets = { + @JinjavaSnippet( + desc = "Set a variable in with a set statement and print the variable in a expression", + code = "{% set primaryColor = \"#F7761F\" %}\n" + "{{ primaryColor }}\n" + ), + @JinjavaSnippet( + desc = "You can combine multiple values or variables into a sequence variable", + code = "{% set var_one = \"String 1\" %}\n" + + "{% set var_two = \"String 2\" %}\n" + + "{% set sequence = [var_one, var_two] %}" + ), + @JinjavaSnippet( + desc = "You can set a value to the string value within a block", + code = "{% set name = 'Jack' %}\n" + + "{% set message %}\n" + + "My name is {{ name }}\n" + + "{% endset %}" + ), + } +) +@JinjavaTextMateSnippet(code = "{% set ${1:var} = ${2:expr} %}") +public class SetTag implements Tag, FlexibleTag { + + public static final String TAG_NAME = "set"; + public static final String IGNORED_VARIABLE_NAME = "__ignored__"; private static final long serialVersionUID = -8558479410226781539L; - private static final String TAGNAME = "set"; @Override public String getName() { - return TAGNAME; + return TAG_NAME; } @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { if (!tagNode.getHelpers().contains("=")) { - throw new InterpretException("Tag 'set' expects an assignment expression with '=', but was: " + tagNode.getHelpers(), tagNode.getLineNumber()); + return interpretBlockSet(tagNode, interpreter); } int eqPos = tagNode.getHelpers().indexOf('='); String var = tagNode.getHelpers().substring(0, eqPos).trim(); - String expr = tagNode.getHelpers().substring(eqPos + 1, tagNode.getHelpers().length()); + String expr = tagNode.getHelpers().substring(eqPos + 1); if (var.length() == 0) { - throw new InterpretException("Tag 'set' requires a var name to assign to", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'set' requires a var name to assign to", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } if (StringUtils.isBlank(expr)) { - throw new InterpretException("Tag 'set' requires an expression to assign to a var", tagNode.getLineNumber()); + throw new TemplateSyntaxException( + tagNode.getMaster().getImage(), + "Tag 'set' requires an expression to assign to a var", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); } - Object val = interpreter.resolveELExpression(expr, tagNode.getLineNumber()); - interpreter.getContext().put(var, val); + String[] varTokens = var.split(","); + + try { + @SuppressWarnings("unchecked") + List exprVals = (List) interpreter.resolveELExpression( + "[" + expr + "]", + tagNode.getMaster().getLineNumber() + ); + executeSet((TagToken) tagNode.getMaster(), interpreter, varTokens, exprVals, false); + } catch (DeferredValueException e) { + DeferredValueUtils.deferVariables(varTokens, interpreter.getContext()); + throw e; + } + + return ""; + } + private String interpretBlockSet(TagNode tagNode, JinjavaInterpreter interpreter) { + int filterPos = tagNode.getHelpers().indexOf('|'); + String var = tagNode.getHelpers().trim(); + if (filterPos >= 0) { + var = tagNode.getHelpers().substring(0, filterPos).trim(); + } + String result; + result = renderChildren(tagNode, interpreter, var); + try { + executeSetBlock(tagNode, var, result, filterPos >= 0, interpreter); + } catch (DeferredValueException e) { + DeferredValueUtils.deferVariables(new String[] { var }, interpreter.getContext()); + throw e; + } return ""; } + public static String renderChildren( + TagNode tagNode, + JinjavaInterpreter interpreter, + String var + ) { + String result; + if (IGNORED_VARIABLE_NAME.equals(var)) { + result = renderChildren(tagNode, interpreter); + } else { + try (InterpreterScopeClosable c = interpreter.enterScope()) { + result = renderChildren(tagNode, interpreter); + } + } + return result; + } + + private static String renderChildren(TagNode tagNode, JinjavaInterpreter interpreter) { + String result; + StringBuilder sb = new StringBuilder(); + for (Node child : tagNode.getChildren()) { + sb.append(child.render(interpreter)); + } + result = sb.toString(); + return result; + } + + private void executeSetBlock( + TagNode tagNode, + String var, + String resolvedBlock, + boolean hasFilterOp, + JinjavaInterpreter interpreter + ) { + String[] varAsArray = new String[] { var }; + executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + varAsArray, + Collections.singletonList(resolvedBlock), + false + ); + if (hasFilterOp) { + // Evaluate the whole expression to get the filtered result + Object finalVal = interpreter.resolveELExpression( + tagNode.getHelpers().trim(), + tagNode.getMaster().getLineNumber() + ); + executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + varAsArray, + Collections.singletonList(finalVal), + false + ); + } + } + + public void executeSet( + TagToken tagToken, + JinjavaInterpreter interpreter, + String[] varTokens, + List resolvedList, + boolean allowDeferredValueOverride + ) { + if (varTokens.length > 1) { + // handle multi-variable assignment + + if (resolvedList == null || varTokens.length != resolvedList.size()) { + throw new TemplateSyntaxException( + tagToken.getImage(), + "Tag 'set' declares an uneven number of variables and assigned values", + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + + for (int i = 0; i < varTokens.length; i++) { + String varItem = varTokens[i].trim(); + if (interpreter.getContext().containsKey(varItem)) { + if ( + !allowDeferredValueOverride && + interpreter.getContext().get(varItem) instanceof DeferredValue + ) { + throw new DeferredValueException(varItem); + } + } + interpreter.getContext().put(varItem, resolvedList.get(i)); + } + } else { + // handle single variable assignment + if (interpreter.getContext().containsKey(varTokens[0])) { + if ( + !allowDeferredValueOverride && + interpreter.getContext().get(varTokens[0]) instanceof DeferredValue + ) { + throw new DeferredValueException(varTokens[0]); + } + } + setVariable( + interpreter, + varTokens[0], + resolvedList != null && resolvedList.size() > 0 ? resolvedList.get(0) : null + ); + } + } + + private void setVariable(JinjavaInterpreter interpreter, String var, Object value) { + if (var.contains(".")) { + String[] varArray = var.split("\\.", 2); + Object namespace = interpreter.getContext().get(varArray[0]); + + if (namespace instanceof Namespace) { + ((Namespace) namespace).put(varArray[1], value); + return; + } + if (namespace instanceof DeferredValue) { + throw new DeferredValueException("Deferred Namespace"); + } + } + if (!IGNORED_VARIABLE_NAME.equals(var)) { + interpreter.getContext().put(var, value); + } + } + @Override public String getEndTagName() { - return null; + return "endset"; } + @Override + public boolean hasEndTag(TagToken tagToken) { + return !tagToken.getHelpers().contains("="); + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/Tag.java b/src/main/java/com/hubspot/jinjava/lib/tag/Tag.java index 8c36b30e1..c8425b75b 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/Tag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/Tag.java @@ -1,33 +1,42 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.lib.tag; -import java.io.Serializable; - import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.lib.Importable; import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import java.io.Serializable; public interface Tag extends Importable, Serializable { + default OutputNode interpretOutput(TagNode tagNode, JinjavaInterpreter interpreter) { + return new RenderedOutputNode(interpret(tagNode, interpreter)); + } String interpret(TagNode tagNode, JinjavaInterpreter interpreter); /** - * @return Get name of end tag lowerCase Null if it's a single tag without content. + * @return Get name of end tag (lowerCase). Null if it's a single tag without content. */ - String getEndTagName(); + default String getEndTagName() { + return String.format("end%s", getName()); + } + default boolean isRenderedInValidationMode() { + return false; + } } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java b/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java index 2c24c71d0..1a2155001 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java @@ -16,34 +16,39 @@ package com.hubspot.jinjava.lib.tag; import com.hubspot.jinjava.lib.SimpleLibrary; +import java.util.Set; public class TagLibrary extends SimpleLibrary { - public TagLibrary(boolean registerDefaults) { - super(registerDefaults); + public TagLibrary(boolean registerDefaults, Set disabled) { + super(registerDefaults, disabled); } @Override protected void registerDefaults() { registerClasses( - AutoEscapeTag.class, - BlockTag.class, - CallTag.class, - CycleTag.class, - ElseTag.class, - ElseIfTag.class, - ExtendsTag.class, - ForTag.class, - FromTag.class, - IfTag.class, - IfchangedTag.class, - ImportTag.class, - IncludeTag.class, - MacroTag.class, - PrintTag.class, - RawTag.class, - SetTag.class, - UnlessTag.class); + AutoEscapeTag.class, + BlockTag.class, + BreakTag.class, + CallTag.class, + ContinueTag.class, + CycleTag.class, + ElseTag.class, + ElseIfTag.class, + ExtendsTag.class, + ForTag.class, + FromTag.class, + IfTag.class, + IfchangedTag.class, + ImportTag.class, + IncludeTag.class, + MacroTag.class, + PrintTag.class, + RawTag.class, + SetTag.class, + UnlessTag.class, + DoTag.class + ); } public Tag getTag(String tagName) { @@ -62,5 +67,4 @@ public void register(Tag t) { register(t.getEndTagName(), new EndTag(t)); } } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/UnlessTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/UnlessTag.java index 65ff4570b..90afb734c 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/UnlessTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/UnlessTag.java @@ -1,8 +1,10 @@ package com.hubspot.jinjava.lib.tag; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.util.ObjectTruthValue; @@ -17,30 +19,41 @@ */ @JinjavaDoc( - value = "Unless is a conditional just like 'if' but works on the inverse logic.", - params = @JinjavaParam(value = "expr", type = "expression", desc = "Condition to evaluate"), - snippets = @JinjavaSnippet(code = "{% unless x < 0 %} x is greater than zero {% endunless %}")) + value = "Unless is a conditional just like 'if' but works on the inverse logic.", + params = @JinjavaParam( + value = "expr", + type = "expression", + desc = "Condition to evaluate" + ), + snippets = @JinjavaSnippet( + code = "{% unless x < 0 %} x is greater than zero {% endunless %}" + ) +) +@JinjavaHasCodeBody +@JinjavaTextMateSnippet(code = "{% unless ${1:condition} %}\n\t$0\n{% endunless %}") public class UnlessTag extends IfTag { + public static final String TAG_NAME = "unless"; + private static final long serialVersionUID = 1562284758153763419L; @Override public String getName() { - return "unless"; + return TAG_NAME; } @Override - public String getEndTagName() { - return "endunless"; - } - - @Override - protected boolean evaluateIfElseTagNode(TagNode tagNode, JinjavaInterpreter interpreter) { + public boolean isPositiveIfElseNode(TagNode tagNode, JinjavaInterpreter interpreter) { if (tagNode.getName().equals("unless")) { - return !ObjectTruthValue.evaluate(interpreter.resolveELExpression(tagNode.getHelpers(), tagNode.getLineNumber())); + return !ObjectTruthValue.evaluate( + interpreter.resolveELExpression( + tagNode.getHelpers(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ) + ); } - return super.evaluateIfElseTagNode(tagNode, interpreter); + return super.isPositiveIfElseNode(tagNode, interpreter); } - } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/DeferredToken.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/DeferredToken.java new file mode 100644 index 000000000..f8c5df393 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/DeferredToken.java @@ -0,0 +1,449 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.interpret.CallStack; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredLazyReference; +import com.hubspot.jinjava.interpret.DeferredLazyReferenceSource; +import com.hubspot.jinjava.interpret.DeferredMacroValueImpl; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueShadow; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.tree.parse.Token; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Beta +public class DeferredToken { + + public static class DeferredTokenBuilder { + + private final Token token; + private ImmutableSet.Builder usedDeferredWords; + private ImmutableSet.Builder setDeferredWords; + + private DeferredTokenBuilder(Token token) { + this.token = token; + } + + public DeferredToken build() { + return new DeferredToken( + token, + usedDeferredWords != null ? usedDeferredWords.build() : Collections.emptySet(), + setDeferredWords != null ? setDeferredWords.build() : Collections.emptySet(), + acquireImportResourcePath(), + acquireMacroStack() + ); + } + + public DeferredTokenBuilder addUsedDeferredWords( + Collection usedDeferredWordsToAdd + ) { + if (usedDeferredWords == null) { + usedDeferredWords = ImmutableSet.builder(); + } + usedDeferredWords.addAll(usedDeferredWordsToAdd); + return this; + } + + public DeferredTokenBuilder addUsedDeferredWords( + Stream usedDeferredWordsToAdd + ) { + if (usedDeferredWords == null) { + usedDeferredWords = ImmutableSet.builder(); + } + usedDeferredWordsToAdd.forEach(usedDeferredWords::add); + return this; + } + + public DeferredTokenBuilder addSetDeferredWords( + Collection setDeferredWordsToAdd + ) { + if (setDeferredWords == null) { + setDeferredWords = ImmutableSet.builder(); + } + setDeferredWords.addAll(setDeferredWordsToAdd); + return this; + } + + public DeferredTokenBuilder addSetDeferredWords( + Stream setDeferredWordsToAdd + ) { + if (setDeferredWords == null) { + setDeferredWords = ImmutableSet.builder(); + } + setDeferredWordsToAdd.forEach(setDeferredWords::add); + return this; + } + } + + private final Token token; + // These words aren't yet DeferredValues, but are unresolved + // so they should be replaced with DeferredValueImpls if they exist in the context + private final Set usedDeferredWords; + private final Set usedDeferredBases; + // These words are those which will be set to a value which has been deferred. + private final Set setDeferredWords; + private final Set setDeferredBases; + + // Used to determine the combine scope + private final CallStack macroStack; + + // Used to determine if in separate file + private final String importResourcePath; + + /** + * Create a {@link DeferredTokenBuilder} with the provided {@link Token} {@code token} + * @param token A {@link Token} with a deferred image + * @return DeferredTokenBuilder + */ + public static DeferredTokenBuilder builderFromToken(Token token) { + return new DeferredTokenBuilder(token); + } + + /** + * Create a {@link DeferredTokenBuilder} with a {@link Token} constructed using the constructor of {@code tokenClass} using + * the provided {@code image} and line number, position, and symbols taken from the {@code interpreter}. + * @param image The deferred token image + * @param tokenClass Class of {@link Token} to create + * @param interpreter The {@link JinjavaInterpreter} + * @return DeferredTokenBuilder + * @param generic type of the {@tokenClass}, which extends {@link Token} + */ + public static DeferredTokenBuilder builderFromImage( + String image, + Class tokenClass, + JinjavaInterpreter interpreter + ) { + return builderFromToken( + constructToken( + tokenClass, + image, + interpreter.getLineNumber(), + interpreter.getPosition(), + interpreter.getConfig().getTokenScannerSymbols() + ) + ); + } + + /** + * Create a {@link DeferredTokenBuilder} with a {@link Token} constructed using the provided {@code image} + * and line number, position, and symbols taken from the {@code originalToken}. + * @param image The deferred token image + * @param originalToken Original {@link Token} to reference for attributes + * @return DeferredTokenBuilder + */ + public static DeferredTokenBuilder builderFromImage(String image, Token originalToken) { + return builderFromToken( + constructToken( + originalToken.getClass(), + image, + originalToken.getLineNumber(), + originalToken.getStartPosition(), + originalToken.getSymbols() + ) + ); + } + + private static T constructToken( + Class tokenClass, + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + try { + return tokenClass + .getDeclaredConstructor( + String.class, + int.class, + int.class, + TokenScannerSymbols.class + ) + .newInstance(image, lineNumber, startPosition, symbols); + } catch ( + InstantiationException + | IllegalAccessException + | InvocationTargetException + | NoSuchMethodException e + ) { + throw new RuntimeException(e); + } + } + + /** + * @deprecated Use {@link #builderFromToken(Token)} + */ + @Deprecated + public DeferredToken(Token token, Set usedDeferredWords) { + this(token, usedDeferredWords, Collections.emptySet()); + } + + /** + * @deprecated Use {@link #builderFromToken(Token)} + */ + @Deprecated + public DeferredToken( + Token token, + Set usedDeferredWords, + Set setDeferredWords + ) { + this( + token, + usedDeferredWords, + setDeferredWords, + acquireImportResourcePath(), + acquireMacroStack() + ); + } + + private DeferredToken( + Token token, + Set usedDeferredWords, + Set setDeferredWords, + String importResourcePath, + CallStack macroStack + ) { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + this.token = token; + this.usedDeferredBases = + usedDeferredWords.isEmpty() + ? Collections.emptySet() + : usedDeferredWords + .stream() + .map(DeferredToken::splitToken) + .map(DeferredToken::getFirstNonEmptyToken) + .distinct() + .filter(word -> + interpreter == null || + !(interpreter.getContext().get(word) instanceof DeferredMacroValueImpl) + ) + .collect(Collectors.toSet()); + this.usedDeferredWords = usedDeferredWords; + this.setDeferredBases = + setDeferredWords.isEmpty() + ? Collections.emptySet() + : setDeferredWords + .stream() + .map(DeferredToken::splitToken) + .map(DeferredToken::getFirstNonEmptyToken) + .collect(Collectors.toSet()); + this.setDeferredWords = setDeferredWords; + this.importResourcePath = importResourcePath; + this.macroStack = macroStack; + } + + public Token getToken() { + return token; + } + + public Set getUsedDeferredWords() { + return usedDeferredWords; + } + + public Set getUsedDeferredBases() { + return usedDeferredBases; + } + + public Set getSetDeferredWords() { + return setDeferredWords; + } + + public Set getSetDeferredBases() { + return setDeferredBases; + } + + public String getImportResourcePath() { + return importResourcePath; + } + + public CallStack getMacroStack() { + return macroStack; + } + + public void addTo(Context context) { + addTo( + context, + usedDeferredBases + .stream() + .filter(word -> { + Object value = context.get(word); + return value != null && !(value instanceof DeferredValue); + }) + .collect(Collectors.toCollection(HashSet::new)) + ); + } + + private void addTo(Context context, Set wordsWithoutDeferredSource) { + context.getDeferredTokens().add(this); + deferPropertiesOnContext(context, wordsWithoutDeferredSource); + if (context.getParent() != null) { + Context parent = context.getParent(); + //Ignore global context + if (parent.getParent() != null) { + addTo(parent, wordsWithoutDeferredSource); + } else { + context.checkNumberOfDeferredTokens(); + } + } + } + + private void deferPropertiesOnContext( + Context context, + Set wordsWithoutDeferredSource + ) { + if (isInSameScope(context)) { + // set props are only deferred when within the scope which the variable is set in + markDeferredWordsAndFindSources(context, getSetDeferredBases(), true); + } + wordsWithoutDeferredSource.forEach(word -> deferDuplicatePointers(context, word)); + wordsWithoutDeferredSource.removeAll( + markDeferredWordsAndFindSources(context, wordsWithoutDeferredSource, false) + ); + } + + private boolean isInSameScope(Context context) { + return (getMacroStack() == null || getMacroStack() == context.getMacroStack()); + } + + // If 'list_a' and 'list_b' reference the same object, and 'list_a' is getting deferred, also defer 'list_b' + private static void deferDuplicatePointers(Context context, String word) { + Object wordValue = context.get(word); + + if ( + !(wordValue instanceof DeferredValue) && + !EagerExpressionResolver.isPrimitive(wordValue) + ) { + DeferredLazyReference deferredLazyReference = DeferredLazyReference.instance( + context, + word + ); + Context temp = context; + Set> matchingEntries = new HashSet<>(); + while (temp.getParent() != null) { + temp + .getScope() + .entrySet() + .stream() + .filter(entry -> + entry.getValue() == wordValue || + (entry.getValue() instanceof DeferredValue && + ((DeferredValue) entry.getValue()).getOriginalValue() == wordValue) + ) + .forEach(entry -> { + matchingEntries.add(entry); + deferredLazyReference.getOriginalValue().setReferenceKey(entry.getKey()); + }); + temp = temp.getParent(); + } + if (matchingEntries.size() > 1) { // at least one duplicate + matchingEntries.forEach(entry -> { + if ( + deferredLazyReference + .getOriginalValue() + .getReferenceKey() + .equals(entry.getKey()) + ) { + convertToDeferredLazyReferenceSource(context, entry); + } else { + entry.setValue(deferredLazyReference.clone()); + } + }); + } + } + } + + private static void convertToDeferredLazyReferenceSource( + Context context, + Entry entry + ) { + Object val = entry.getValue(); + if (val instanceof DeferredLazyReferenceSource) { + return; + } + DeferredLazyReferenceSource deferredLazyReferenceSource = + DeferredLazyReferenceSource.instance( + val instanceof DeferredValue ? ((DeferredValue) val).getOriginalValue() : val + ); + + context.replace(entry.getKey(), deferredLazyReferenceSource); + entry.setValue(deferredLazyReferenceSource); + } + + private static Collection markDeferredWordsAndFindSources( + Context context, + Set wordsToDefer, + boolean replacing + ) { + return wordsToDefer + .stream() + .filter(prop -> { + Object val = context.get(prop); + if (replacing) { + return ( + !(val instanceof DeferredValue) || context.getScope().containsKey(prop) + ); + } + return !(val instanceof DeferredValue); + }) + .filter(prop -> !MetaContextVariables.isMetaContextVariable(prop, context)) + .filter(prop -> { + DeferredValue deferredValue = convertToDeferredValue(context, prop); + context.put(prop, deferredValue); + return !(deferredValue instanceof DeferredValueShadow); + }) + .collect(Collectors.toList()); + } + + private static DeferredValue convertToDeferredValue(Context context, String prop) { + Object valueInScope = context.getScope().get(prop); + Object value = context.get(prop); + if (value instanceof DeferredValue) { + value = ((DeferredValue) value).getOriginalValue(); + } + if (value != null) { + if (valueInScope == null) { + return DeferredValue.shadowInstance(value); + } else { + return DeferredValue.instance(value); + } + } + return DeferredValue.instance(); + } + + private static String acquireImportResourcePath() { + return (String) JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getContext().get(Context.IMPORT_RESOURCE_PATH_KEY)) + .filter(path -> path instanceof String) + .orElse(null); + } + + private static CallStack acquireMacroStack() { + return JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getContext().getMacroStack()) + .orElse(null); + } + + private static String getFirstNonEmptyToken(List strings) { + return Strings.isNullOrEmpty(strings.get(0)) ? strings.get(1) : strings.get(0); + } + + public static List splitToken(String token) { + return Arrays.asList(token.split("\\.")); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerBlockSetTagStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerBlockSetTagStrategy.java new file mode 100644 index 000000000..e812ef967 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerBlockSetTagStrategy.java @@ -0,0 +1,274 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.RawTag; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult.ResolutionState; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.commons.lang3.tuple.Triple; + +@Beta +public class EagerBlockSetTagStrategy extends EagerSetTagStrategy { + + public static final EagerBlockSetTagStrategy INSTANCE = new EagerBlockSetTagStrategy( + new SetTag() + ); + + protected EagerBlockSetTagStrategy(SetTag setTag) { + super(setTag); + } + + @Override + protected EagerExecutionResult getEagerExecutionResult( + TagNode tagNode, + String[] variables, + String expression, + JinjavaInterpreter interpreter + ) { + EagerExecutionResult eagerExecutionResult = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromSupplier( + () -> SetTag.renderChildren(tagNode, eagerInterpreter, variables[0]), + eagerInterpreter + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .build() + ); + if ( + (!eagerExecutionResult.getResult().isFullyResolved() && + !eagerExecutionResult.getSpeculativeBindings().isEmpty()) || + interpreter.getContext().isDeferredExecutionMode() + ) { + EagerReconstructionUtils.resetAndDeferSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + eagerExecutionResult = + unwrapRawTagsIfFullyResolved(interpreter, eagerExecutionResult); + return eagerExecutionResult; + } + + private static EagerExecutionResult unwrapRawTagsIfFullyResolved( + JinjavaInterpreter interpreter, + EagerExecutionResult eagerExecutionResult + ) { + if ( + eagerExecutionResult.getResult().isFullyResolved() && + eagerExecutionResult + .getResult() + .toString(true) + .contains( + interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag() + + " " + + RawTag.TAG_NAME + ) + ) { + try ( + TemporaryValueClosable temporaryValueClosable = interpreter + .getContext() + .withUnwrapRawOverride() + ) { + eagerExecutionResult = + new EagerExecutionResult( + EagerExpressionResult.fromString( + interpreter.renderFlat(eagerExecutionResult.asTemplateString()), + ResolutionState.FULL + ), + eagerExecutionResult.getSpeculativeBindings() + ); + } + } + return eagerExecutionResult; + } + + @Override + protected Optional resolveSet( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + int filterPos = tagNode.getHelpers().indexOf('|'); + try { + setTag.executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + variables, + eagerExecutionResult.getResult().toList(), + true + ); + if (filterPos >= 0) { + EagerExecutionResult filterResult = + EagerInlineSetTagStrategy.INSTANCE.getEagerExecutionResult( + tagNode, + variables, + tagNode.getHelpers().trim(), + interpreter + ); + if (filterResult.getResult().isFullyResolved()) { + setTag.executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + variables, + filterResult.getResult().toList(), + true + ); + } else { + // We could evaluate the block part, and just need to defer the filtering. + return Optional.of( + runInlineStrategy(tagNode, variables, filterResult, interpreter) + ); + } + } + return Optional.of(""); + } catch (DeferredValueException ignored) {} + return Optional.empty(); + } + + @Override + protected Triple getPrefixTokenAndSuffix( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ) + .add(tagNode.getSymbols().getExpressionStartWithTag()) + .add(tagNode.getTag().getName()) + .add(variables[0]) + .add(tagNode.getSymbols().getExpressionEndWithTag()); + + PrefixToPreserveState prefixToPreserveState = getPrefixToPreserveState( + eagerExecutionResult, + variables, + interpreter + ) + .withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagNode.getMaster()) + .addUsedDeferredWords(getNamespaceRootsForDottedSet(variables, interpreter)) + .addSetDeferredWords(Stream.of(variables)) + .build() + ) + ); + String suffixToPreserveState = getSuffixToPreserveState(variables, interpreter); + return Triple.of( + prefixToPreserveState.toString(), + joiner.toString(), + suffixToPreserveState + ); + } + + @Override + protected void attemptResolve( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + try { + // try to override the value for just this context + setTag.executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + variables, + eagerExecutionResult.getResult().toList(), + true + ); + } catch (DeferredValueException ignored) {} + } + + @Override + protected String buildImage( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + Triple triple, + JinjavaInterpreter interpreter + ) { + int filterPos = tagNode.getHelpers().indexOf('|'); + String filterSetPostfix = ""; + if (filterPos >= 0) { + EagerExecutionResult filterResult = + EagerInlineSetTagStrategy.INSTANCE.getEagerExecutionResult( + tagNode, + variables, + tagNode.getHelpers().trim(), + interpreter + ); + if (filterResult.getResult().isFullyResolved()) { + setTag.executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + variables, + filterResult.getResult().toList(), + true + ); + } + filterSetPostfix = runInlineStrategy(tagNode, variables, filterResult, interpreter); + } + + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + triple.getLeft() + + triple.getMiddle() + + eagerExecutionResult.getResult().toString(true) + + EagerReconstructionUtils.reconstructEnd(tagNode) + + filterSetPostfix + + triple.getRight(), + interpreter + ); + } + + private String runInlineStrategy( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + Triple triple = + EagerInlineSetTagStrategy.INSTANCE.getPrefixTokenAndSuffix( + tagNode, + variables, + eagerExecutionResult, + interpreter + ); + if ( + eagerExecutionResult.getResult().isFullyResolved() && + interpreter.getContext().isDeferredExecutionMode() + ) { + EagerInlineSetTagStrategy.INSTANCE.attemptResolve( + tagNode, + variables, + eagerExecutionResult, + interpreter + ); + } + return EagerInlineSetTagStrategy.INSTANCE.buildImage( + tagNode, + variables, + eagerExecutionResult, + triple, + interpreter + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCallTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCallTag.java new file mode 100644 index 000000000..128289fd5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCallTag.java @@ -0,0 +1,167 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.lib.expression.EagerExpressionStrategy; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.lib.tag.CallTag; +import com.hubspot.jinjava.lib.tag.FlexibleTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.LinkedHashMap; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerCallTag extends EagerStateChangingTag { + + public EagerCallTag() { + super(new CallTag()); + } + + public EagerCallTag(CallTag tag) { + super(tag); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + interpreter.getContext().checkNumberOfDeferredTokens(); + MacroFunction caller; + EagerExecutionResult eagerExecutionResult; + PrefixToPreserveState prefixToPreserveState; + LengthLimitingStringJoiner joiner; + try (InterpreterScopeClosable c = interpreter.enterNonStackingScope()) { + caller = + new EagerMacroFunction( + tagNode.getChildren(), + "caller", + new LinkedHashMap<>(), + true, + interpreter.getContext(), + interpreter.getLineNumber(), + interpreter.getPosition() + ); + interpreter.getContext().addGlobalMacro(caller); + eagerExecutionResult = + EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResolver.resolveExpression( + tagNode.getHelpers().trim(), + interpreter + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .withPartialMacroEvaluation( + interpreter.getConfig().isNestedInterpretationEnabled() + ) + .build() + ); + prefixToPreserveState = new PrefixToPreserveState(); + if ( + !eagerExecutionResult.getResult().isFullyResolved() || + interpreter.getContext().isDeferredExecutionMode() + ) { + prefixToPreserveState.putAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + if (eagerExecutionResult.getResult().isFullyResolved()) { + // Possible macro/set tag in front of this one. + return ( + prefixToPreserveState.toString() + + EagerExpressionStrategy.postProcessResult( + new ExpressionToken( + tagNode.getHelpers(), + tagNode.getLineNumber(), + tagNode.getStartPosition(), + tagNode.getSymbols() + ), + eagerExecutionResult.getResult().toString(true), + interpreter + ) + ); + } + + caller.setDeferred(true); + // caller() needs to exist here so that the macro function can be reconstructed + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExecutionResult.getResult().getDeferredWords(), + interpreter + ); + } + + // Now preserve those variables from the scope the call tag was called in + prefixToPreserveState.withAllInFront( + new EagerExecutionResult( + eagerExecutionResult.getResult(), + eagerExecutionResult.getSpeculativeBindings() + ) + .getPrefixToPreserveState() + ); + joiner = + new LengthLimitingStringJoiner(interpreter.getConfig().getMaxOutputSize(), " "); + joiner + .add(tagNode.getSymbols().getExpressionStartWithTag()) + .add(tagNode.getTag().getName()) + .add(eagerExecutionResult.getResult().toString().trim()) + .add(tagNode.getSymbols().getExpressionEndWithTag()); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagNode.getMaster()) + .addUsedDeferredWords(eagerExecutionResult.getResult().getDeferredWords()) + .build() + ) + ); + + StringBuilder result = new StringBuilder(prefixToPreserveState + joiner.toString()); + interpreter.getContext().setDynamicVariableResolver(s -> DeferredValue.instance()); + if (!tagNode.getChildren().isEmpty()) { + result.append( + EagerContextWatcher + .executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString(renderChildren(tagNode, eagerInterpreter)), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ) + .asTemplateString() + ); + } + if ( + StringUtils.isNotBlank(tagNode.getEndName()) && + (!(getTag() instanceof FlexibleTag) || + ((FlexibleTag) getTag()).hasEndTag((TagToken) tagNode.getMaster())) + ) { + result.append(EagerReconstructionUtils.reconstructEnd(tagNode)); + } // Possible set tag in front of this one. + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + result.toString(), + interpreter + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerContinueTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerContinueTag.java new file mode 100644 index 000000000..1aaef5e43 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerContinueTag.java @@ -0,0 +1,33 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.ContinueTag; +import com.hubspot.jinjava.lib.tag.ForTag; +import com.hubspot.jinjava.tree.parse.TagToken; + +/** + * Eager decorator for the continue tag that handles reconstruction when the continue + * is inside a deferred context (e.g., when in deferred execution mode such as + * inside a deferred if condition within a for loop). + */ +@Beta +public class EagerContinueTag extends EagerTagDecorator { + + public EagerContinueTag() { + super(new ContinueTag()); + } + + public EagerContinueTag(ContinueTag continueTag) { + super(continueTag); + } + + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + if (!(interpreter.getContext().get(ForTag.LOOP) instanceof DeferredValue)) { + interpreter.getContext().replace(ForTag.LOOP, DeferredValue.instance()); + } + return super.getEagerTagImage(tagToken, interpreter); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTag.java new file mode 100644 index 000000000..4dc0917bf --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTag.java @@ -0,0 +1,258 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.tag.CycleTag; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.HelperStringTokenizer; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import com.hubspot.jinjava.util.WhitespaceUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Beta +public class EagerCycleTag extends EagerStateChangingTag { + + public EagerCycleTag() { + super(new CycleTag()); + } + + public EagerCycleTag(CycleTag cycleTag) { + super(cycleTag); + } + + @SuppressWarnings("unchecked") + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + HelperStringTokenizer tk = new HelperStringTokenizer(tagToken.getHelpers()); + + List helper = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (String token : tk.allTokens()) { + sb.append(token); + if (!token.endsWith(",")) { + helper.add(sb.toString()); + sb = new StringBuilder(); + } + } + if (sb.length() > 0) { + helper.add(sb.toString()); + } + String expression = '[' + helper.get(0) + ']'; + EagerExecutionResult eagerExecutionResult = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResolver.resolveExpression(expression, interpreter), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .build() + ); + + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if ( + !eagerExecutionResult.getResult().isFullyResolved() || + interpreter.getContext().isDeferredExecutionMode() + ) { + prefixToPreserveState.putAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + String resolvedExpression; + List resolvedValues; // can only be retrieved if the EagerExpressionResult are fully resolved. + if ( + eagerExecutionResult + .getResult() + .toString() + .equals(EagerExpressionResolver.JINJAVA_EMPTY_STRING) + ) { + resolvedExpression = normalizeResolvedExpression(expression); // Cycle tag defaults to input on null + resolvedValues = + new HelperStringTokenizer(resolvedExpression).splitComma(true).allTokens(); + } else { + resolvedExpression = + normalizeResolvedExpression(eagerExecutionResult.getResult().toString()); + if (!eagerExecutionResult.getResult().isFullyResolved()) { + resolvedValues = + new HelperStringTokenizer(resolvedExpression).splitComma(true).allTokens(); + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExecutionResult.getResult().getDeferredWords(), + interpreter + ); + } else { + List objects = eagerExecutionResult.getResult().toList(); + if (objects.size() == 1 && objects.get(0) instanceof List) { + // because we may have wrapped in an extra set of brackets + objects = (List) objects.get(0); + } + resolvedValues = + objects.stream().map(interpreter::getAsString).collect(Collectors.toList()); + for (int i = 0; i < resolvedValues.size(); i++) { + resolvedValues.set( + i, + interpreter.resolveString( + resolvedValues.get(i), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ) + ); + } + } + } + if (helper.size() == 1) { + // The helpers get printed out + return ( + prefixToPreserveState.toString() + + interpretPrintingCycle( + tagToken, + interpreter, + resolvedValues, + resolvedExpression, + eagerExecutionResult.getResult() + ) + ); + } else if (helper.size() == 3) { + // The helpers get set to a new variable + return ( + prefixToPreserveState.toString() + + interpretSettingCycle( + interpreter, + resolvedValues, + helper, + resolvedExpression, + eagerExecutionResult.getResult().isFullyResolved() + ) + ); + } else { + throw new TemplateSyntaxException( + tagToken.getImage(), + "Tag 'cycle' expects 1 or 3 helper(s), was: " + helper.size(), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + } + + private String normalizeResolvedExpression(String resolvedExpression) { + resolvedExpression = resolvedExpression.replace(", ", ","); + resolvedExpression = resolvedExpression.substring(1, resolvedExpression.length() - 1); + if (WhitespaceUtils.isWrappedWith(resolvedExpression, "[", "]")) { + resolvedExpression = + resolvedExpression.substring(1, resolvedExpression.length() - 1); + } + return resolvedExpression; + } + + private String interpretSettingCycle( + JinjavaInterpreter interpreter, + List values, + List helper, + String resolvedExpression, + boolean fullyResolved + ) { + String var = helper.get(2); + if (!fullyResolved) { + return EagerReconstructionUtils.buildSetTag( + ImmutableMap.of( + var, + String.format("[%s]", resolvedExpression.replace(",", ", ")) + ), + interpreter, + true + ); + } + interpreter.getContext().put(var, values); + return ""; + } + + private String interpretPrintingCycle( + TagToken tagToken, + JinjavaInterpreter interpreter, + List values, + String resolvedExpression, + EagerExpressionResult eagerExpressionResult + ) { + if (interpreter.getContext().isDeferredExecutionMode()) { + String reconstructedTag = reconstructCycleTag(resolvedExpression, tagToken); + return ( + reconstructedTag + + new PrefixToPreserveState( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(reconstructedTag, tagToken) + .addUsedDeferredWords(eagerExpressionResult.getDeferredWords()) + .build() + ) + ) + ); + } + Integer forindex = (Integer) interpreter.retraceVariable( + CycleTag.LOOP_INDEX, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + if (forindex == null) { + forindex = 0; + } + if (values.size() == 1) { + String var = values.get(0); + if (!eagerExpressionResult.isFullyResolved()) { + return getIsIterable(var, forindex, tagToken); + } else { + return var; + } + } + String item = values.get(forindex % values.size()); + if ( + !eagerExpressionResult.isFullyResolved() && + EagerExpressionResolver.shouldBeEvaluated(item, interpreter) + ) { + return String.format("{{ %s }}", values.get(forindex % values.size())); + } + return item; + } + + private String reconstructCycleTag(String expression, TagToken tagToken) { + return String.format( + "%s cycle %s %s", + tagToken.getSymbols().getExpressionStartWithTag(), + expression, + tagToken.getSymbols().getExpressionEndWithTag() + ); + } + + private static String getIsIterable(String var, int forIndex, TagToken tagToken) { + String tokenStart = tagToken.getSymbols().getExpressionStartWithTag(); + String tokenEnd = tagToken.getSymbols().getExpressionEndWithTag(); + return ( + String.format( + "%s if exptest:iterable.evaluate(%s, null) %s", + tokenStart, + var, + tokenEnd + ) + + // modulo indexing + String.format( + "{{ %s[%d %% filter:length.filter(%s, ____int3rpr3t3r____)] }}", + var, + forIndex, + var + ) + + String.format("%s else %s", tokenStart, tokenEnd) + + String.format("{{ %s }}", var) + + String.format("%s endif %s", tokenStart, tokenEnd) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTag.java new file mode 100644 index 000000000..87eb19292 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTag.java @@ -0,0 +1,77 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.FlexibleTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; + +@Beta +public class EagerDoTag extends EagerStateChangingTag implements FlexibleTag { + + public EagerDoTag() { + super(new DoTag()); + } + + public EagerDoTag(DoTag doTag) { + super(doTag); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + if (hasEndTag((TagToken) tagNode.getMaster())) { + EagerExecutionResult eagerExecutionResult = + EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromSupplier( + () -> renderChildren(tagNode, interpreter), + eagerInterpreter + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .build() + ); + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if (interpreter.getContext().isDeferredExecutionMode()) { + prefixToPreserveState.withAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + if (eagerExecutionResult.getResult().isFullyResolved()) { + return (prefixToPreserveState.toString()); + } + return EagerReconstructionUtils.wrapInTag( + eagerExecutionResult.asTemplateString(), + getName(), + interpreter, + true + ); + } + return EagerPrintTag.interpretExpression( + tagNode.getHelpers(), + (TagToken) tagNode.getMaster(), + interpreter, + false + ); + } + + @Override + public boolean hasEndTag(TagToken tagToken) { + return getTag().hasEndTag(tagToken); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerExecutionResult.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerExecutionResult.java new file mode 100644 index 000000000..1d9c1cf4e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerExecutionResult.java @@ -0,0 +1,107 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static com.hubspot.jinjava.util.EagerReconstructionUtils.buildSetTag; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredLazyReference; +import com.hubspot.jinjava.interpret.DeferredLazyReferenceSource; +import com.hubspot.jinjava.interpret.DeferredValueShadow; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.AbstractMap; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +/** + * This represents the result of speculatively executing an expression, where if something + * got deferred, then the prefixToPreserveState can be added to the output + * that would preserve the state for a second pass. + */ +@Beta +public class EagerExecutionResult { + + private final EagerExpressionResult result; + private final Map speculativeBindings; + private PrefixToPreserveState prefixToPreserveState; + + public EagerExecutionResult( + EagerExpressionResult result, + Map speculativeBindings + ) { + this.result = result; + this.speculativeBindings = speculativeBindings; + } + + public EagerExpressionResult getResult() { + return result; + } + + public Map getSpeculativeBindings() { + return speculativeBindings; + } + + public PrefixToPreserveState getPrefixToPreserveState() { + if (prefixToPreserveState != null) { + return prefixToPreserveState; + } + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + prefixToPreserveState = new PrefixToPreserveState(); + Collection> filteredEntries = speculativeBindings + .entrySet() + .stream() + .filter(entry -> { + Object contextValue = interpreter.getContext().get(entry.getKey()); + if (contextValue instanceof DeferredLazyReferenceSource) { + ((DeferredLazyReferenceSource) contextValue).setReconstructed(true); + } + return !(contextValue instanceof DeferredValueShadow); + }) + .collect(Collectors.toList()); + filteredEntries + .stream() + .filter(entry -> !(entry.getValue() instanceof DeferredLazyReference)) + .forEach(entry -> + EagerReconstructionUtils.hydrateBlockOrInlineSetTagRecursively( + prefixToPreserveState, + entry.getKey(), + entry.getValue(), + interpreter + ) + ); + filteredEntries + .stream() + .filter(entry -> (entry.getValue() instanceof DeferredLazyReference)) + .map(entry -> + new AbstractMap.SimpleImmutableEntry<>( + entry.getKey(), + PyishObjectMapper.getAsPyishString( + ((DeferredLazyReference) entry.getValue()).getOriginalValue() + ) + ) + ) + .sorted((a, b) -> + a.getValue().equals(b.getKey()) ? 1 : b.getValue().equals(a.getKey()) ? -1 : 0 + ) + .forEach(entry -> + prefixToPreserveState.put( + entry.getKey(), + buildSetTag( + Collections.singletonMap(entry.getKey(), entry.getValue()), + interpreter, + false + ) + ) + ); + return prefixToPreserveState; + } + + public String asTemplateString() { + return getPrefixToPreserveState().toString() + result.toString(true); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java new file mode 100644 index 000000000..f87be2e68 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java @@ -0,0 +1,262 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.CannotReconstructValueException; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.ForTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult.ResolutionState; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.tuple.Pair; + +@Beta +public class EagerForTag extends EagerTagDecorator { + + public EagerForTag() { + super(new ForTag()); + } + + public EagerForTag(ForTag forTag) { + super(forTag); + } + + @Override + public String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Pair, String> loopVarsAndExpression = getTag() + .getLoopVarsAndExpression((TagToken) tagNode.getMaster()); + EagerExecutionResult collectionResult = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResolver.resolveExpression( + '[' + loopVarsAndExpression.getRight() + ']', + interpreter + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withCheckForContextChanges(!interpreter.getContext().isDeferredExecutionMode()) + .build() + ); + if (collectionResult.getResult().isFullyResolved()) { + Set addedTokens = new HashSet<>(); + EagerExecutionResult result = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> { + EagerExpressionResult expressionResult = EagerExpressionResult.fromSupplier( + () -> { + try { + interpreter + .getContext() + .addNonMetaContextVariables(loopVarsAndExpression.getLeft()); + return getTag() + .renderForCollection( + tagNode, + eagerInterpreter, + loopVarsAndExpression.getLeft(), + !collectionResult.getResult().toList().isEmpty() + ? collectionResult.getResult().toList().get(0) + : Collections.emptyList() + ); + } finally { + interpreter + .getContext() + .removeNonMetaContextVariables(loopVarsAndExpression.getLeft()); + } + }, + eagerInterpreter + ); + addedTokens.addAll(eagerInterpreter.getContext().getDeferredTokens()); + return expressionResult; + }, + interpreter, + EagerContextWatcher.EagerChildContextConfig.newBuilder().build() + ); + if (result.getResult().getResolutionState() == ResolutionState.NONE) { + EagerReconstructionUtils.resetSpeculativeBindings(interpreter, collectionResult); + EagerReconstructionUtils.resetSpeculativeBindings(interpreter, result); + interpreter.getContext().removeDeferredTokens(addedTokens); + throw new DeferredValueException(result.getResult().toString(true)); + } + if (result.getResult().isFullyResolved()) { + return result.getResult().toString(true); + } else { + return ( + result + .getPrefixToPreserveState() + .withAllInFront(collectionResult.getPrefixToPreserveState()) + + EagerReconstructionUtils.wrapInChildScope( + result.getResult().toString(true), + interpreter + ) + ); + } + } + EagerReconstructionUtils.resetSpeculativeBindings(interpreter, collectionResult); + throw new DeferredValueException(collectionResult.getResult().toString(true)); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + if (e instanceof CannotReconstructValueException) { + throw e; + } + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + + try ( + TemporaryValueClosable c = interpreter + .getContext() + .withDeferLargeObjects( + ForTag.TOO_LARGE_EXCEPTION_MESSAGE.equals(e.getMessage()) || + interpreter.getContext().isDeferLargeObjects() + ) + ) { + // separate getEagerImage from renderChildren because the token gets evaluated once + // while the children are evaluated 0...n times. + result.append( + EagerContextWatcher + .executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString( + getEagerImage( + buildToken( + tagNode, + e, + interpreter.getLineNumber(), + interpreter.getPosition() + ), + eagerInterpreter + ) + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig.newBuilder().build() + ) + .asTemplateString() + ); + } + + EagerExecutionResult firstRunResult = runLoopOnce(tagNode, interpreter, true); + PrefixToPreserveState prefixToPreserveState = firstRunResult + .getPrefixToPreserveState() + .withAllInFront( + EagerReconstructionUtils.resetAndDeferSpeculativeBindings( + interpreter, + firstRunResult + ) + ); + // Run for loop again now that the necessary values have been deferred + EagerExecutionResult secondRunResult = runLoopOnce(tagNode, interpreter, false); + if ( + secondRunResult + .getSpeculativeBindings() + .keySet() + .stream() + .anyMatch(key -> !firstRunResult.getSpeculativeBindings().containsKey(key)) + ) { + throw new DeferredValueException( + "Modified values in deferred for loop: " + + String.join(", ", secondRunResult.getSpeculativeBindings().keySet()) + ); + } + + result.append(secondRunResult.asTemplateString()); + result.append(EagerReconstructionUtils.reconstructEnd(tagNode)); + return prefixToPreserveState.toString() + result; + } + + private EagerExecutionResult runLoopOnce( + TagNode tagNode, + JinjavaInterpreter interpreter, + boolean clearDeferredWords + ) { + return EagerContextWatcher.executeInChildContext( + eagerInterpreter -> { + if (!(eagerInterpreter.getContext().get(ForTag.LOOP) instanceof DeferredValue)) { + eagerInterpreter.getContext().put(ForTag.LOOP, DeferredValue.instance()); + } + List loopVars = getTag() + .getLoopVarsAndExpression((TagToken) tagNode.getMaster()) + .getLeft(); + interpreter.getContext().addNonMetaContextVariables(loopVars); + loopVars.forEach(var -> + interpreter.getContext().put(var, DeferredValue.instance()) + ); + try { + return EagerExpressionResult.fromString( + renderChildren(tagNode, eagerInterpreter) + ); + } finally { + interpreter.getContext().removeNonMetaContextVariables(loopVars); + if (clearDeferredWords) { + interpreter + .getContext() + .removeDeferredTokens(interpreter.getContext().getDeferredTokens()); + } + } + }, + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ); + } + + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + Pair, String> loopVarsAndExpression = getTag() + .getLoopVarsAndExpression(tagToken); + List loopVars = loopVarsAndExpression.getLeft(); + String loopExpression = loopVarsAndExpression.getRight(); + + EagerExpressionResult eagerExpressionResult = + EagerExpressionResolver.resolveExpression(loopExpression, interpreter); + + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ); + + joiner + .add(tagToken.getSymbols().getExpressionStartWithTag()) + .add(tagToken.getTagName()) + .add(String.join(", ", loopVars)) + .add("in") + .add(eagerExpressionResult.toString()) + .add(tagToken.getSymbols().getExpressionEndWithTag()); + PrefixToPreserveState prefixToPreserveState = + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + new PrefixToPreserveState(), + eagerExpressionResult.getDeferredWords(), + interpreter + ); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagToken) + .addUsedDeferredWords(eagerExpressionResult.getDeferredWords()) + .build() + ) + ); + return (prefixToPreserveState + joiner.toString()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTag.java new file mode 100644 index 000000000..7a0ed06c6 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTag.java @@ -0,0 +1,187 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ImmutableMap; +import com.hubspot.algebra.Result; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.FromTag; +import com.hubspot.jinjava.lib.tag.eager.importing.EagerImportingStrategyFactory; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Beta +public class EagerFromTag extends EagerStateChangingTag { + + public EagerFromTag() { + super(new FromTag()); + } + + public EagerFromTag(FromTag fromTag) { + super(fromTag); + } + + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + String initialPathSetter = EagerImportingStrategyFactory.getSetTagForCurrentPath( + interpreter + ); + List helper = FromTag.getHelpers(tagToken); + Map imports = FromTag.getImportMap(helper); + AutoCloseableSupplier> maybeTemplateFileSupplier; + try { + maybeTemplateFileSupplier = + FromTag.getTemplateFileWithWrapper(helper, tagToken, interpreter); + } catch (DeferredValueException e) { + imports + .values() + .forEach(value -> { + MacroFunction deferredMacro = new EagerMacroFunction( + null, + value, + null, + false, + null, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + deferredMacro.setDeferred(true); + interpreter.getContext().addGlobalMacro(deferredMacro); + }); + return ( + initialPathSetter + + new PrefixToPreserveState( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromToken(tagToken) + .addUsedDeferredWords(Stream.of(helper.get(0))) + .addUsedDeferredWords(imports.keySet()) + .addSetDeferredWords(imports.values()) + .build() + ) + ) + + tagToken.getImage() + ); + } + try ( + AutoCloseableImpl> maybeTemplateFile = + maybeTemplateFileSupplier.get() + ) { + return maybeTemplateFile + .value() + .match( + err -> { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "From cycle detected for path: '" + err.getPath() + "'", + null, + tagToken.getLineNumber(), + tagToken.getStartPosition(), + err, + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("path", err.getPath()) + ) + ); + return ""; + }, + templateFile -> { + try { + String template = interpreter.getResource(templateFile); + Node node = interpreter.parse(template); + + JinjavaInterpreter child = interpreter + .getConfig() + .getInterpreterFactory() + .newInstance(interpreter); + child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, templateFile); + String output; + output = child.render(node); + + interpreter.addAllChildErrors(templateFile, child.getErrorsCopy()); + + if (!child.getContext().getDeferredNodes().isEmpty()) { + FromTag.handleDeferredNodesDuringImport( + tagToken, + templateFile, + imports, + child, + interpreter + ); + } + + FromTag.integrateChild(imports, child, interpreter); + Map newToOldImportNames = getNewToOldWithoutMacros( + imports, + interpreter + ); + if (child.getContext().getDeferredTokens().isEmpty() || output == null) { + return ""; + } else if (newToOldImportNames.size() > 0) { + // Set after output + output = + output + + EagerReconstructionUtils.buildSetTag( + newToOldImportNames, + interpreter, + true + ); + } + return EagerReconstructionUtils.wrapInTag( + output, + DoTag.TAG_NAME, + interpreter, + true + ); + } catch (IOException e) { + throw new InterpretException( + e.getMessage(), + e, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + } + ); + } + } + + private static Map getNewToOldWithoutMacros( + Map oldToNewImportNames, + JinjavaInterpreter interpreter + ) { + return oldToNewImportNames + .entrySet() + .stream() + .filter(e -> !e.getKey().equals(e.getValue())) + .filter(e -> + interpreter.getContext().containsKey(e.getValue()) || + !interpreter.getContext().isGlobalMacro(e.getValue()) + ) + .collect(Collectors.toMap(Entry::getValue, Entry::getKey)); // flip order + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerGenericTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerGenericTag.java new file mode 100644 index 000000000..f3917141e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerGenericTag.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.lib.tag.Tag; + +@Beta +public class EagerGenericTag extends EagerTagDecorator implements Tag { + + public EagerGenericTag(T tag) { + super(tag); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTag.java new file mode 100644 index 000000000..dddc449c4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTag.java @@ -0,0 +1,230 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.tag.ElseIfTag; +import com.hubspot.jinjava.lib.tag.ElseTag; +import com.hubspot.jinjava.lib.tag.IfTag; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.HashSet; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerIfTag extends EagerTagDecorator { + + public EagerIfTag() { + super(new IfTag()); + } + + public EagerIfTag(IfTag ifTag) { + super(ifTag); + } + + @Override + public String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + return getTag().interpret(tagNode, interpreter); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + if (StringUtils.isBlank(tagNode.getHelpers())) { + throw new TemplateSyntaxException( + interpreter, + tagNode.getMaster().getImage(), + "Tag 'if' expects expression" + ); + } + + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + + result.append( + EagerContextWatcher + .executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString( + eagerRenderBranches(tagNode, eagerInterpreter, e) + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ) + .asTemplateString() + ); + tagNode.getMaster().setRightTrimAfterEnd(false); + result.append(EagerReconstructionUtils.reconstructEnd(tagNode)); + + return result.toString(); + } + + public String eagerRenderBranches( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + // line number of the last attempted resolveELExpression + final int deferredLineNumber = interpreter.getLineNumber(); + final int deferredPosition = interpreter.getPosition(); + // If the branch is impossible, it should be removed. + boolean definitelyDrop = shouldDropBranch( + tagNode, + interpreter, + deferredLineNumber, + deferredPosition + ); + // If an ("elseif") branch would definitely get executed, + // change it to an "else" tag and drop all the subsequent branches. + // We know this has to start as false otherwise IfTag would have chosen + // the first branch. + boolean definitelyExecuted = false; + StringBuilder sb = new StringBuilder(); + sb.append( + getEagerImage( + buildToken(tagNode, e, deferredLineNumber, deferredPosition), + interpreter + ) + ); + int branchStart = 0; + int childrenSize = tagNode.getChildren().size(); + Set bindingsToDefer = new HashSet<>(); + while (branchStart < childrenSize) { + int branchEnd = findNextElseToken(tagNode, branchStart); + if (!definitelyDrop) { + int finalBranchStart = branchStart; + EagerExecutionResult result = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString( + evaluateBranch(tagNode, finalBranchStart, branchEnd, interpreter) + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ); + sb.append(result.getResult()); + bindingsToDefer.addAll( + EagerReconstructionUtils.resetSpeculativeBindings(interpreter, result) + ); + } + if (branchEnd >= childrenSize || definitelyExecuted) { + break; + } + TagNode caseNode = (TagNode) tagNode.getChildren().get(branchEnd); + definitelyDrop = + caseNode.getName().equals(ElseIfTag.TAG_NAME) && + shouldDropBranch(caseNode, interpreter, deferredLineNumber, deferredPosition); + if (!definitelyDrop) { + definitelyExecuted = + caseNode.getName().equals(ElseTag.TAG_NAME) || + isDefinitelyExecuted(caseNode, interpreter, deferredLineNumber); + if (definitelyExecuted) { + sb.append( + String.format( + "%s else %s", + caseNode.getSymbols().getExpressionStartWithTag(), + caseNode.getSymbols().getExpressionEndWithTag() + ) + ); + } else { + sb.append( + getEagerImage( + buildToken(caseNode, e, deferredLineNumber, deferredPosition), + interpreter + ) + ); + } + } + branchStart = branchEnd + 1; + } + PrefixToPreserveState prefixToPreserveState = + EagerReconstructionUtils.deferWordsAndReconstructReferences( + interpreter, + bindingsToDefer + ); + return prefixToPreserveState + sb.toString(); + } + + private String evaluateBranch( + TagNode tagNode, + int startIdx, + int endIdx, + JinjavaInterpreter interpreter + ) { + StringBuilder sb = new StringBuilder(); + for (int i = startIdx; i < endIdx; i++) { + Node child = tagNode.getChildren().get(i); + sb.append(child.render(interpreter).getValue()); + } + return sb.toString(); + } + + private int findNextElseToken(TagNode tagNode, int startIdx) { + int i; + for (i = startIdx; i < tagNode.getChildren().size(); i++) { + Node childNode = tagNode.getChildren().get(i); + if ( + ((TagNode.class.isAssignableFrom(childNode.getClass())) && + childNode.getName().equals(ElseIfTag.TAG_NAME)) || + childNode.getName().equals(ElseTag.TAG_NAME) + ) { + return i; + } + } + return i; + } + + private boolean shouldDropBranch( + TagNode tagNode, + JinjavaInterpreter eagerInterpreter, + int deferredLineNumber, + int deferredPosition + ) { + if (deferredLineNumber > tagNode.getLineNumber()) { + return true; // Deferred value thrown on a later branch so we can drop this one. + } else if ( + deferredLineNumber == tagNode.getLineNumber() && + deferredPosition >= tagNode.getStartPosition() + ) { + return deferredPosition > tagNode.getStartPosition(); // false if they are equal + } + // the tag node is after the deferred exception location + try { + return !getTag().isPositiveIfElseNode(tagNode, eagerInterpreter); + } catch (DeferredValueException e) { + return false; + } + } + + private boolean isDefinitelyExecuted( + TagNode tagNode, + JinjavaInterpreter eagerInterpreter, + int deferredLineNumber + ) { + if (deferredLineNumber == tagNode.getLineNumber()) { + return false; // Deferred value thrown when checking if this branch would be executed. + } + try { + return getTag().isPositiveIfElseNode(tagNode, eagerInterpreter); + } catch (DeferredValueException e) { + return false; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTag.java new file mode 100644 index 000000000..70117f3c6 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTag.java @@ -0,0 +1,142 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ImmutableMap; +import com.hubspot.algebra.Result; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.ImportTag; +import com.hubspot.jinjava.lib.tag.eager.importing.EagerImportingStrategy; +import com.hubspot.jinjava.lib.tag.eager.importing.EagerImportingStrategyFactory; +import com.hubspot.jinjava.lib.tag.eager.importing.ImportingData; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import java.io.IOException; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerImportTag extends EagerStateChangingTag { + + public EagerImportTag() { + super(new ImportTag()); + } + + public EagerImportTag(ImportTag importTag) { + super(importTag); + } + + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + ImportingData importingData = EagerImportingStrategyFactory.getImportingData( + tagToken, + interpreter + ); + EagerImportingStrategy eagerImportingStrategy = EagerImportingStrategyFactory.create( + importingData + ); + + try ( + AutoCloseableImpl> templateFileResult = ImportTag + .getTemplateFileWithWrapper(importingData.getHelpers(), tagToken, interpreter) + .get() + ) { + return templateFileResult + .value() + .match( + err -> { + String path = StringUtils.trimToEmpty(importingData.getHelpers().get(0)); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.EXCEPTION, + ErrorItem.TAG, + "Import cycle detected for path: '" + path + "'", + null, + tagToken.getLineNumber(), + tagToken.getStartPosition(), + err, + BasicTemplateErrorCategory.IMPORT_CYCLE_DETECTED, + ImmutableMap.of("path", path) + ) + ); + return ""; + }, + templateFile -> { + try ( + AutoCloseableImpl node = ImportTag + .parseTemplateAsNode(interpreter, templateFile) + .get() + ) { + JinjavaInterpreter child = interpreter + .getConfig() + .getInterpreterFactory() + .newInstance(interpreter); + child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, templateFile); + String output; + eagerImportingStrategy.setup(child); + output = child.render(node.value()); + + interpreter.addAllChildErrors(templateFile, child.getErrorsCopy()); + Map childBindings = child.getContext().getSessionBindings(); + + // If the template depends on deferred values it should not be rendered, + // and all defined variables and macros should be deferred too. + if ( + !child.getContext().getDeferredNodes().isEmpty() || + (interpreter.getContext().isDeferredExecutionMode() && + !child.getContext().getGlobalMacros().isEmpty()) + ) { + ImportTag.handleDeferredNodesDuringImport( + node.value(), + ImportTag.getContextVar(importingData.getHelpers()), + childBindings, + child, + interpreter + ); + throw new DeferredValueException( + templateFile, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + eagerImportingStrategy.integrateChild(child); + if (child.getContext().getDeferredTokens().isEmpty() || output == null) { + return ""; + } + return EagerReconstructionUtils.wrapInTag( + EagerReconstructionUtils.wrapPathAroundText( + eagerImportingStrategy.getFinalOutput(output, child), + templateFile, + interpreter + ), + DoTag.TAG_NAME, + interpreter, + true + ); + } catch (IOException e) { + throw new InterpretException( + e.getMessage(), + e, + tagToken.getLineNumber(), + tagToken.getStartPosition() + ); + } + } + ); + } catch (DeferredValueException e) { + return eagerImportingStrategy.handleDeferredTemplateFile(e); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTag.java new file mode 100644 index 000000000..91450dadf --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTag.java @@ -0,0 +1,30 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.IncludeTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.EagerReconstructionUtils; + +@Beta +public class EagerIncludeTag extends EagerTagDecorator { + + public EagerIncludeTag(IncludeTag tag) { + super(tag); + } + + @Override + public String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + String templateFile = IncludeTag.resolveTemplateFile(tagNode, interpreter); + int numDeferredTokensStart = interpreter.getContext().getDeferredTokens().size(); + String output = super.innerInterpret(tagNode, interpreter); + if (interpreter.getContext().getDeferredTokens().size() > numDeferredTokensStart) { + return EagerReconstructionUtils.wrapPathAroundText( + output, + templateFile, + interpreter + ); + } + return output; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerInlineSetTagStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerInlineSetTagStrategy.java new file mode 100644 index 000000000..d6e359e3c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerInlineSetTagStrategy.java @@ -0,0 +1,136 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import com.hubspot.jinjava.util.WhitespaceUtils; +import java.util.Arrays; +import java.util.Optional; +import org.apache.commons.lang3.tuple.Triple; + +@Beta +public class EagerInlineSetTagStrategy extends EagerSetTagStrategy { + + public static final EagerInlineSetTagStrategy INSTANCE = new EagerInlineSetTagStrategy( + new SetTag() + ); + + protected EagerInlineSetTagStrategy(SetTag setTag) { + super(setTag); + } + + @Override + public EagerExecutionResult getEagerExecutionResult( + TagNode tagNode, + String[] variables, + String expression, + JinjavaInterpreter interpreter + ) { + return EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResolver.resolveExpression('[' + expression + ']', interpreter), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .build() + ); + } + + @Override + public Optional resolveSet( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + try { + setTag.executeSet( + (TagToken) tagNode.getMaster(), + interpreter, + variables, + eagerExecutionResult.getResult().toList(), + true + ); + return Optional.of(""); + } catch (DeferredValueException ignored) {} + return Optional.empty(); + } + + @Override + public Triple getPrefixTokenAndSuffix( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + String deferredResult = eagerExecutionResult.getResult().toString(); + if (WhitespaceUtils.isWrappedWith(deferredResult, "[", "]")) { + deferredResult = deferredResult.substring(1, deferredResult.length() - 1); + } + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ) + .add(tagNode.getSymbols().getExpressionStartWithTag()) + .add(tagNode.getTag().getName()) + .add(String.join(",", variables)) + .add("=") + .add(deferredResult) + .add(tagNode.getSymbols().getExpressionEndWithTag()); + PrefixToPreserveState prefixToPreserveState = getPrefixToPreserveState( + eagerExecutionResult, + variables, + interpreter + ); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagNode.getMaster()) + .addUsedDeferredWords(eagerExecutionResult.getResult().getDeferredWords()) + .addUsedDeferredWords(getNamespaceRootsForDottedSet(variables, interpreter)) + .addSetDeferredWords(Arrays.stream(variables).map(String::trim)) + .build() + ) + ); + String suffixToPreserveState = getSuffixToPreserveState(variables, interpreter); + return Triple.of( + prefixToPreserveState.toString(), + joiner.toString(), + suffixToPreserveState + ); + } + + @Override + public void attemptResolve( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ) { + resolveSet(tagNode, variables, eagerExecutionResult, interpreter); + } + + @Override + public String buildImage( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + Triple triple, + JinjavaInterpreter interpreter + ) { + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + triple.getLeft() + triple.getMiddle() + triple.getRight(), + interpreter + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerMacroTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerMacroTag.java new file mode 100644 index 000000000..5308219a5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerMacroTag.java @@ -0,0 +1,31 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.lib.tag.MacroTag; +import com.hubspot.jinjava.tree.TagNode; +import java.util.LinkedHashMap; + +@Beta +public class EagerMacroTag extends MacroTag { + + @Override + protected MacroFunction constructMacroFunction( + TagNode tagNode, + JinjavaInterpreter interpreter, + String name, + LinkedHashMap argNamesWithDefaults + ) { + return new EagerMacroFunction( + tagNode.getChildren(), + name, + argNamesWithDefaults, + false, + interpreter.getContext(), + interpreter.getLineNumber(), + interpreter.getPosition() + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerPrintTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerPrintTag.java new file mode 100644 index 000000000..e0697dc0a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerPrintTag.java @@ -0,0 +1,119 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.tag.PrintTag; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerPrintTag extends EagerStateChangingTag { + + public EagerPrintTag() { + super(new PrintTag()); + } + + public EagerPrintTag(PrintTag printTag) { + super(printTag); + } + + @Override + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + String expr = tagToken.getHelpers(); + if (StringUtils.isBlank(expr)) { + throw new TemplateSyntaxException( + interpreter, + tagToken.getImage(), + "Tag 'print' expects expression" + ); + } + return interpretExpression(expr, tagToken, interpreter, true); + } + + /** + * Interprets the expression, which may depend on deferred values. + * If the expression can be entirely evaluated, return the result only if + * {@code includeExpressionResult} is true. + * When the expression depends on deferred values, then reconstruct the tag. + * @param expr Expression to interpret. + * @param tagToken TagToken which is calling the expression. + * @param interpreter The Jinjava interpreter. + * @param includeExpressionResult Whether to include the result of the expression in + * the output. + * @return The result of the expression, if requested. OR a reconstruction of the calling tag. + */ + public static String interpretExpression( + String expr, + TagToken tagToken, + JinjavaInterpreter interpreter, + boolean includeExpressionResult + ) { + EagerExecutionResult eagerExecutionResult = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> EagerExpressionResolver.resolveExpression(expr, interpreter), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .build() + ); + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if ( + !eagerExecutionResult.getResult().isFullyResolved() || + interpreter.getContext().isDeferredExecutionMode() + ) { + prefixToPreserveState.putAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + if (eagerExecutionResult.getResult().isFullyResolved()) { + // Possible macro/set tag in front of this one. + return ( + prefixToPreserveState.toString() + + (includeExpressionResult + ? EagerReconstructionUtils.wrapInRawIfNeeded( + eagerExecutionResult.getResult().toString(true), + interpreter + ) + : "") + ); + } + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExecutionResult.getResult().getDeferredWords(), + interpreter + ); + + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ); + joiner + .add(tagToken.getSymbols().getExpressionStartWithTag()) + .add(tagToken.getTagName()) + .add(eagerExecutionResult.getResult().toString().trim()) + .add(tagToken.getSymbols().getExpressionEndWithTag()); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagToken) + .addUsedDeferredWords(eagerExecutionResult.getResult().getDeferredWords()) + .build() + ) + ); + // Possible set tag in front of this one. + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + prefixToPreserveState.toString() + joiner.toString(), + interpreter + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTag.java new file mode 100644 index 000000000..bf37796c4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTag.java @@ -0,0 +1,45 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.FlexibleTag; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; + +@Beta +public class EagerSetTag extends EagerStateChangingTag implements FlexibleTag { + + public EagerSetTag() { + super(new SetTag()); + } + + public EagerSetTag(SetTag setTag) { + super(setTag); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + if (tagNode.getHelpers().contains("=")) { + return EagerInlineSetTagStrategy.INSTANCE.run( + new TagNode( + getTag(), + buildToken(tagNode, e, interpreter.getLineNumber(), interpreter.getPosition()), + tagNode.getSymbols() + ), + interpreter + ); + } + return EagerBlockSetTagStrategy.INSTANCE.run(tagNode, interpreter); + } + + @Override + public boolean hasEndTag(TagToken tagToken) { + return getTag().hasEndTag(tagToken); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java new file mode 100644 index 000000000..1747d004f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java @@ -0,0 +1,255 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.lang3.tuple.Triple; + +@Beta +public abstract class EagerSetTagStrategy { + + protected final SetTag setTag; + + protected EagerSetTagStrategy(SetTag setTag) { + this.setTag = setTag; + } + + protected static Stream getNamespaceRootsForDottedSet( + String[] variables, + JinjavaInterpreter interpreter + ) { + return Arrays + .stream(variables) + .map(String::trim) + .filter(variable -> variable.contains(".")) + .map(variable -> variable.substring(0, variable.indexOf('.'))) + .distinct() + .filter(root -> interpreter.getContext().get(root) instanceof Namespace); + } + + public String run(TagNode tagNode, JinjavaInterpreter interpreter) { + int eqPos = tagNode.getHelpers().indexOf('='); + String[] variables; + String expression; + if (eqPos > 0) { + variables = tagNode.getHelpers().substring(0, eqPos).trim().split(","); + expression = tagNode.getHelpers().substring(eqPos + 1).trim(); + } else { + int filterPos = tagNode.getHelpers().indexOf('|'); + String var = tagNode.getHelpers().trim(); + if (filterPos >= 0) { + var = tagNode.getHelpers().substring(0, filterPos).trim(); + } + variables = new String[] { var }; + expression = tagNode.getHelpers(); + } + interpreter + .getContext() + .addNonMetaContextVariables( + Arrays.stream(variables).map(String::trim).collect(Collectors.toList()) + ); + + EagerExecutionResult eagerExecutionResult = getEagerExecutionResult( + tagNode, + variables, + expression, + interpreter + ); + boolean triedResolve = false; + if ( + eagerExecutionResult.getResult().isFullyResolved() && + !interpreter.getContext().isDeferredExecutionMode() && + (Arrays + .stream(variables) + .noneMatch(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY::equals) || + interpreter.getContext().getPenultimateParent().getDeferredTokens().isEmpty()) // Prevents set tags from disappearing in nested interpretation + ) { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + Optional maybeResolved = resolveSet( + tagNode, + variables, + eagerExecutionResult, + interpreter + ); + triedResolve = true; + if (maybeResolved.isPresent()) { + return maybeResolved.get(); + } + } + Triple triple = getPrefixTokenAndSuffix( + tagNode, + variables, + eagerExecutionResult, + interpreter + ); + if (eagerExecutionResult.getResult().isFullyResolved() && !triedResolve) { + attemptResolve(tagNode, variables, eagerExecutionResult, interpreter); + } + return buildImage(tagNode, variables, eagerExecutionResult, triple, interpreter); + } + + protected abstract EagerExecutionResult getEagerExecutionResult( + TagNode tagNode, + String[] variables, + String expression, + JinjavaInterpreter interpreter + ); + + protected abstract Optional resolveSet( + TagNode tagNode, + String[] variables, + EagerExecutionResult resolvedValues, + JinjavaInterpreter interpreter + ); + + protected abstract Triple getPrefixTokenAndSuffix( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + JinjavaInterpreter interpreter + ); + + protected abstract void attemptResolve( + TagNode tagNode, + String[] variables, + EagerExecutionResult resolvedValues, + JinjavaInterpreter interpreter + ); + + protected abstract String buildImage( + TagNode tagNode, + String[] variables, + EagerExecutionResult eagerExecutionResult, + Triple triple, + JinjavaInterpreter interpreter + ); + + protected PrefixToPreserveState getPrefixToPreserveState( + EagerExecutionResult eagerExecutionResult, + String[] variables, + JinjavaInterpreter interpreter + ) { + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if ( + !eagerExecutionResult.getResult().isFullyResolved() || + interpreter.getContext().isDeferredExecutionMode() + ) { + prefixToPreserveState.putAll(eagerExecutionResult.getPrefixToPreserveState()); + } else { + EagerReconstructionUtils.commitSpeculativeBindings( + interpreter, + eagerExecutionResult + ); + } + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExecutionResult.getResult().getDeferredWords(), + interpreter + ); + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + Arrays + .stream(variables) + .filter(var -> var.contains(".")) + .collect(Collectors.toSet()), + interpreter + ); + return prefixToPreserveState; + } + + public static String getSuffixToPreserveState( + List varList, + JinjavaInterpreter interpreter + ) { + if (varList.isEmpty()) { + return ""; + } + return getSuffixToPreserveState(varList.stream(), interpreter); + } + + public static String getSuffixToPreserveState( + String[] varList, + JinjavaInterpreter interpreter + ) { + if (varList.length == 0) { + return ""; + } + return getSuffixToPreserveState(Arrays.stream(varList), interpreter); + } + + private static String getSuffixToPreserveState( + Stream varStream, + JinjavaInterpreter interpreter + ) { + StringBuilder suffixToPreserveState = new StringBuilder(); + Optional maybeTemporaryImportAlias = interpreter + .getContext() + .getImportResourceAlias() + .map(MetaContextVariables::getTemporaryImportAlias); + if (maybeTemporaryImportAlias.isPresent()) { + boolean stillInsideImportTag = interpreter + .getContext() + .containsKey(maybeTemporaryImportAlias.get()); + List filteredVars = varStream + .filter(var -> + !MetaContextVariables.isMetaContextVariable(var, interpreter.getContext()) + ) + .peek(var -> { + if (!stillInsideImportTag) { + if ( + interpreter.retraceVariable( + String.format( + "%s.%s", + interpreter.getContext().getImportResourceAlias().get(), + var + ), + -1 + ) != + null + ) { + throw new DeferredValueException( + "Cannot modify temporary import alias outside of import tag" + ); + } + } + }) + .collect(Collectors.toList()); + if (filteredVars.isEmpty()) { + return ""; + } + String updateString = getUpdateString(filteredVars); + // Don't need to render because the temporary import alias's value is always deferred, and rendering will do nothing + suffixToPreserveState.append( + EagerReconstructionUtils.buildDoUpdateTag( + maybeTemporaryImportAlias.get(), + updateString, + interpreter + ) + ); + } + return suffixToPreserveState.toString(); + } + + private static String getUpdateString(List varList) { + StringJoiner updateString = new StringJoiner(","); + // Update the alias map to the value of the set variable. + varList.forEach(var -> updateString.add(String.format("'%s': %s", var, var))); + return "{" + updateString + "}"; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerStateChangingTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerStateChangingTag.java new file mode 100644 index 000000000..5f0b19e81 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerStateChangingTag.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.FlexibleTag; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerStateChangingTag extends EagerTagDecorator { + + public EagerStateChangingTag(T tag) { + super(tag); + } + + @Override + public final String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + return eagerInterpret(tagNode, interpreter, null); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + StringBuilder result = new StringBuilder( + getEagerImage( + buildToken(tagNode, e, interpreter.getLineNumber(), interpreter.getPosition()), + interpreter + ) + ); + + if (!tagNode.getChildren().isEmpty()) { + result.append( + EagerContextWatcher + .executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString(renderChildren(tagNode, eagerInterpreter)), + interpreter, + EagerContextWatcher.EagerChildContextConfig.newBuilder().build() + ) + .asTemplateString() + ); + } + if ( + StringUtils.isNotBlank(tagNode.getEndName()) && + (!(getTag() instanceof FlexibleTag) || + ((FlexibleTag) getTag()).hasEndTag((TagToken) tagNode.getMaster())) + ) { + result.append(EagerReconstructionUtils.reconstructEnd(tagNode)); + } + + return result.toString(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecorator.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecorator.java new file mode 100644 index 000000000..c6b73218f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecorator.java @@ -0,0 +1,249 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.tree.parse.Token; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import com.hubspot.jinjava.util.LengthLimitingStringJoiner; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import org.apache.commons.lang3.StringUtils; + +@Beta +public abstract class EagerTagDecorator implements Tag { + + private final T tag; + + public EagerTagDecorator(T tag) { + this.tag = tag; + } + + public T getTag() { + return tag; + } + + @Override + public final String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + try { + String output; + try { + output = innerInterpret(tagNode, interpreter); + } catch (DeferredValueException | TemplateSyntaxException e) { + return wrapEagerInterpret(tagNode, interpreter, e); + } + if (JinjavaInterpreter.isOutputTooLarge(output)) { + return wrapEagerInterpret(tagNode, interpreter, null); + } + return output; + } catch (OutputTooBigException e) { + throw new DeferredValueException( + String.format("Output too big for eager execution: %s", e.getMessage()) + ); + } + } + + private String wrapEagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + RuntimeException e + ) { + return EagerReconstructionUtils.wrapInAutoEscapeIfNeeded( + eagerInterpret( + tagNode, + interpreter, + e instanceof InterpretException + ? (InterpretException) e + : new InterpretException("Exception with default render", e) + ), + interpreter + ); + } + + protected String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + return tag.interpret(tagNode, interpreter); + } + + @Override + public String getName() { + return tag.getName(); + } + + @Override + public String getEndTagName() { + return tag.getEndTagName(); + } + + @Override + public boolean isRenderedInValidationMode() { + return tag.isRenderedInValidationMode(); + } + + /** + * Return the string value of interpreting this tag node knowing that + * a deferred value has been encountered. + * The tag node can not simply get evaluated normally in this circumstance. + * @param tagNode TagNode to interpret. + * @param interpreter The JinjavaInterpreter. + * @param e The exception that required non-default interpretation. May be null + * @return The string result of performing an eager interpretation of the TagNode + */ + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + result.append( + EagerContextWatcher + .executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString( + getEagerImage( + buildToken( + tagNode, + e, + interpreter.getLineNumber(), + interpreter.getPosition() + ), + eagerInterpreter + ) + + renderChildren(tagNode, eagerInterpreter) + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ) + .asTemplateString() + ); + + if (StringUtils.isNotBlank(tagNode.getEndName())) { + result.append(EagerReconstructionUtils.reconstructEnd(tagNode)); + } + + return result.toString(); + } + + public TagToken buildToken( + TagNode tagNode, + InterpretException e, + int deferredLineNumber, + int deferredPosition + ) { + if ( + e instanceof DeferredParsingException && + deferredLineNumber == tagNode.getLineNumber() && + deferredPosition == tagNode.getStartPosition() + ) { + return new TagToken( + String.format( + "%s %s %s %s", // like {% elif deferred %} + tagNode.getSymbols().getExpressionStartWithTag(), + tagNode.getName(), + ((DeferredParsingException) e).getDeferredEvalResult(), + tagNode.getSymbols().getExpressionEndWithTag() + ), + tagNode.getLineNumber(), + tagNode.getStartPosition(), + tagNode.getSymbols() + ); + } + return (TagToken) tagNode.getMaster(); + } + + /** + * Render all children of this TagNode. + * @param tagNode TagNode to render the children of. + * @param interpreter The JinjavaInterpreter. + * @return the string output of this tag node's children. + */ + public String renderChildren(TagNode tagNode, JinjavaInterpreter interpreter) { + StringBuilder sb = new StringBuilder(); + for (Node child : tagNode.getChildren()) { + sb.append(child.render(interpreter).getValue()); + } + return sb.toString(); + } + + /** + * Casts token to TagToken if possible to get the eager image of the token. + * @see #getEagerTagImage(TagToken, JinjavaInterpreter) + * @param token Token to cast. + * @param interpreter The Jinjava interpreter. + * @return The image of the token which has been evaluated as much as possible. + */ + public final String getEagerImage(Token token, JinjavaInterpreter interpreter) { + interpreter.setLineNumber(token.getLineNumber()); + String eagerImage; + if (token instanceof TagToken) { + eagerImage = getEagerTagImage((TagToken) token, interpreter); + } else { + throw new DeferredValueException("Unsupported Token type"); + } + return eagerImage; + } + + /** + * Uses the {@link EagerExpressionResolver} to partially evaluate any expression within + * the tagToken's helpers. If there are any macro functions that must be deferred, + * then their images are pre-pended to the result, which is the partial image + * of the {@link TagToken}. + * @param tagToken TagToken to get the eager image of. + * @param interpreter The Jinjava interpreter. + * @return A new image of the tagToken, which may have expressions that are further + * resolved than in the original {@link TagToken#getImage()}. + */ + public String getEagerTagImage(TagToken tagToken, JinjavaInterpreter interpreter) { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ); + joiner + .add(tagToken.getSymbols().getExpressionStartWithTag()) + .add(tagToken.getTagName()); + + EagerExpressionResult eagerExpressionResult = + EagerExpressionResolver.resolveExpression( + tagToken.getHelpers().trim(), + interpreter + ); + String resolvedString = eagerExpressionResult.toString(); + if (StringUtils.isNotBlank(resolvedString)) { + joiner.add(resolvedString); + } + joiner.add(tagToken.getSymbols().getExpressionEndWithTag()); + + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + EagerReconstructionUtils.hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + eagerExpressionResult.getDeferredWords(), + interpreter + ); + prefixToPreserveState.withAllInFront( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(joiner.toString(), tagToken) + .addUsedDeferredWords(eagerExpressionResult.getDeferredWords()) + .build() + ) + ); + + return (prefixToPreserveState + joiner.toString()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java new file mode 100644 index 000000000..af6b354ae --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java @@ -0,0 +1,91 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.lib.tag.BlockTag; +import com.hubspot.jinjava.lib.tag.CallTag; +import com.hubspot.jinjava.lib.tag.ContinueTag; +import com.hubspot.jinjava.lib.tag.CycleTag; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.ElseIfTag; +import com.hubspot.jinjava.lib.tag.ElseTag; +import com.hubspot.jinjava.lib.tag.EndTag; +import com.hubspot.jinjava.lib.tag.ExtendsTag; +import com.hubspot.jinjava.lib.tag.ForTag; +import com.hubspot.jinjava.lib.tag.FromTag; +import com.hubspot.jinjava.lib.tag.IfTag; +import com.hubspot.jinjava.lib.tag.ImportTag; +import com.hubspot.jinjava.lib.tag.IncludeTag; +import com.hubspot.jinjava.lib.tag.MacroTag; +import com.hubspot.jinjava.lib.tag.PrintTag; +import com.hubspot.jinjava.lib.tag.RawTag; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.lib.tag.UnlessTag; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Beta +public class EagerTagFactory { + + public static final Map, Class>> EAGER_TAG_OVERRIDES = + ImmutableMap + ., Class>>builder() + .put(SetTag.class, EagerSetTag.class) + .put(DoTag.class, EagerDoTag.class) + .put(PrintTag.class, EagerPrintTag.class) + .put(FromTag.class, EagerFromTag.class) + .put(ImportTag.class, EagerImportTag.class) + .put(IncludeTag.class, EagerIncludeTag.class) + .put(ForTag.class, EagerForTag.class) + .put(CycleTag.class, EagerCycleTag.class) + .put(IfTag.class, EagerIfTag.class) + .put(UnlessTag.class, EagerUnlessTag.class) + .put(CallTag.class, EagerCallTag.class) + .put(ContinueTag.class, EagerContinueTag.class) + .build(); + // These classes don't need an eager decorator. + public static final Set> TAG_CLASSES_TO_SKIP = ImmutableSet + .>builder() + .add(BlockTag.class) + .add(EndTag.class) + .add(ElseIfTag.class) + .add(ElseTag.class) + .add(RawTag.class) + .add(ExtendsTag.class) // TODO support reconstructing extends tags + .build(); + + @SuppressWarnings("unchecked") + public static Optional> getEagerTagDecorator( + T tag + ) { + Class clazz = tag.getClass(); + try { + if (TAG_CLASSES_TO_SKIP.contains(clazz)) { + return Optional.empty(); + } + Class> eagerOverrideClass = + EAGER_TAG_OVERRIDES.get(clazz); + if (eagerOverrideClass != null) { + EagerTagDecorator decorator = eagerOverrideClass + .getDeclaredConstructor(clazz) + .newInstance(tag); + if (decorator.getTag().getClass() == clazz) { + return Optional.of((EagerTagDecorator) decorator); + } + } + if (tag instanceof MacroTag) { + return Optional.of(new EagerGenericTag<>((T) new EagerMacroTag())); + } + return Optional.of(new EagerGenericTag<>(tag)); + } catch (NoSuchMethodException e) { + return Optional.empty(); + } catch (Exception e) { + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTag.java new file mode 100644 index 000000000..202737688 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTag.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.lib.tag.UnlessTag; + +@Beta +public class EagerUnlessTag extends EagerIfTag { + + public EagerUnlessTag() { + super(new UnlessTag()); + } + + public EagerUnlessTag(UnlessTag unlessTag) { + super(unlessTag); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/AliasedEagerImportingStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/AliasedEagerImportingStrategy.java new file mode 100644 index 000000000..fa211714f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/AliasedEagerImportingStrategy.java @@ -0,0 +1,257 @@ +package com.hubspot.jinjava.lib.tag.eager.importing; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import com.hubspot.jinjava.util.PrefixToPreserveState; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.stream.Stream; + +public class AliasedEagerImportingStrategy implements EagerImportingStrategy { + + private final ImportingData importingData; + private final String currentImportAlias; + private final String fullImportAlias; + + @VisibleForTesting + public AliasedEagerImportingStrategy( + ImportingData importingData, + String currentImportAlias + ) { + this.importingData = importingData; + this.currentImportAlias = currentImportAlias; + Optional maybeParentImportAlias = importingData + .getOriginalInterpreter() + .getContext() + .getImportResourceAlias(); + if (maybeParentImportAlias.isPresent()) { + fullImportAlias = + String.format("%s.%s", maybeParentImportAlias.get(), currentImportAlias); + } else { + fullImportAlias = currentImportAlias; + } + } + + @Override + public String handleDeferredTemplateFile(DeferredValueException e) { + return ( + importingData.getInitialPathSetter() + + new PrefixToPreserveState( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + importingData.getOriginalInterpreter(), + DeferredToken + .builderFromToken(importingData.getTagToken()) + .addUsedDeferredWords(Stream.of(importingData.getHelpers().get(0))) + .addSetDeferredWords(Stream.of(currentImportAlias)) + .build() + ) + ) + + importingData.getTagToken().getImage() + ); + } + + @Override + public void setup(JinjavaInterpreter child) { + child.getContext().getScope().put(Context.IMPORT_RESOURCE_ALIAS_KEY, fullImportAlias); + child.getContext().put(Context.IMPORT_RESOURCE_ALIAS_KEY, fullImportAlias); + constructFullAliasPathMap(currentImportAlias, child); + getMapForCurrentContextAlias(currentImportAlias, child); + importingData + .getOriginalInterpreter() + .getContext() + .put( + MetaContextVariables.getTemporaryImportAlias(fullImportAlias), + DeferredValue.instance() + ); + } + + @Override + public void integrateChild(JinjavaInterpreter child) { + JinjavaInterpreter parent = importingData.getOriginalInterpreter(); + for (MacroFunction macro : child.getContext().getGlobalMacros().values()) { + if (parent.getContext().isDeferredExecutionMode()) { + macro.setDeferred(true); + } + } + Map childBindings = child.getContext().getSessionBindings(); + childBindings.putAll(child.getContext().getGlobalMacros()); + String temporaryImportAlias = MetaContextVariables.getTemporaryImportAlias( + fullImportAlias + ); + Map mapForCurrentContextAlias = getMapForCurrentContextAlias( + currentImportAlias, + child + ); + childBindings.remove(temporaryImportAlias); + importingData.getOriginalInterpreter().getContext().remove(temporaryImportAlias); + // Remove meta keys + childBindings + .entrySet() + .stream() + .filter(entry -> + !(entry.getKey().equals(Context.GLOBAL_MACROS_SCOPE_KEY) || + entry.getKey().equals(Context.IMPORT_RESOURCE_ALIAS_KEY)) + ) + .forEach(entry -> mapForCurrentContextAlias.put(entry.getKey(), entry.getValue())); + } + + @Override + public String getFinalOutput(String output, JinjavaInterpreter child) { + String temporaryImportAlias = MetaContextVariables.getTemporaryImportAlias( + fullImportAlias + ); + return ( + EagerReconstructionUtils.buildBlockOrInlineSetTag( + temporaryImportAlias, + Collections.emptyMap(), + importingData.getOriginalInterpreter() + ) + + wrapInChildScope( + EagerImportingStrategy.getSetTagForDeferredChildBindings( + child, + currentImportAlias, + child.getContext() + ) + + output, + child + ) + + EagerReconstructionUtils.buildSetTag( + ImmutableMap.of(currentImportAlias, temporaryImportAlias), + importingData.getOriginalInterpreter(), + true + ) + ); + } + + @SuppressWarnings("unchecked") + private static void constructFullAliasPathMap( + String currentImportAlias, + JinjavaInterpreter child + ) { + String fullImportAlias = child + .getContext() + .getImportResourceAlias() + .orElse(currentImportAlias); + String[] allAliases = fullImportAlias.split("\\."); + Map currentMap = child.getContext().getParent(); + for (int i = 0; i < allAliases.length - 1; i++) { + Object maybeNextMap = currentMap.get(allAliases[i]); + if (maybeNextMap instanceof Map) { + currentMap = (Map) maybeNextMap; + } else if ( + maybeNextMap instanceof DeferredValue && + ((DeferredValue) maybeNextMap).getOriginalValue() instanceof Map + ) { + currentMap = + (Map) ((DeferredValue) maybeNextMap).getOriginalValue(); + } else { + throw new InterpretException("Encountered a problem with import alias maps"); + } + } + currentMap.put( + allAliases[allAliases.length - 1], + child.getContext().isDeferredExecutionMode() + ? DeferredValue.instance(new PyMap(new HashMap<>())) + : new PyMap(new HashMap<>()) + ); + } + + @SuppressWarnings("unchecked") + private static Map getMapForCurrentContextAlias( + String currentImportAlias, + JinjavaInterpreter child + ) { + Object parentValueForChild = child + .getContext() + .getParent() + .getSessionBindings() + .get(currentImportAlias); + if (parentValueForChild instanceof Map) { + return (Map) parentValueForChild; + } else if (parentValueForChild instanceof DeferredValue) { + if (((DeferredValue) parentValueForChild).getOriginalValue() instanceof Map) { + return (Map) ((DeferredValue) parentValueForChild).getOriginalValue(); + } + Map newMap = new PyMap(new HashMap<>()); + child + .getContext() + .getParent() + .put(currentImportAlias, DeferredValue.instance(newMap)); + return newMap; + } else { + Map newMap = new PyMap(new HashMap<>()); + child + .getContext() + .getParent() + .put( + currentImportAlias, + child.getContext().isDeferredExecutionMode() + ? DeferredValue.instance(newMap) + : newMap + ); + return newMap; + } + } + + private String wrapInChildScope(String output, JinjavaInterpreter child) { + String combined = + output + getDoTagToPreserve(importingData.getOriginalInterpreter(), child); + // So that any set variables other than the alias won't exist outside the child's scope + return EagerReconstructionUtils.wrapInChildScope( + combined, + importingData.getOriginalInterpreter() + ); + } + + private String getDoTagToPreserve( + JinjavaInterpreter interpreter, + JinjavaInterpreter child + ) { + StringJoiner keyValueJoiner = new StringJoiner(","); + String temporaryImportAlias = MetaContextVariables.getTemporaryImportAlias( + fullImportAlias + ); + Map currentAliasMap = getMapForCurrentContextAlias( + currentImportAlias, + child + ); + for (Map.Entry entry : currentAliasMap.entrySet()) { + if (entry.getKey().equals(temporaryImportAlias)) { + continue; + } + if (entry.getValue() instanceof DeferredValue) { + keyValueJoiner.add(String.format("'%s': %s", entry.getKey(), entry.getKey())); + } else if (!(entry.getValue() instanceof MacroFunction)) { + keyValueJoiner.add( + String.format( + "'%s': %s", + entry.getKey(), + PyishObjectMapper.getAsPyishString(entry.getValue()) + ) + ); + } + } + if (keyValueJoiner.length() > 0) { + return EagerReconstructionUtils.buildDoUpdateTag( + temporaryImportAlias, + "{" + keyValueJoiner.toString() + "}", + interpreter + ); + } + return ""; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategy.java new file mode 100644 index 000000000..33e6fce8f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategy.java @@ -0,0 +1,40 @@ +package com.hubspot.jinjava.lib.tag.eager.importing; + +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import java.util.Map; +import java.util.stream.Collectors; + +public interface EagerImportingStrategy { + String handleDeferredTemplateFile(DeferredValueException e); + void setup(JinjavaInterpreter child); + + void integrateChild(JinjavaInterpreter child); + String getFinalOutput(String output, JinjavaInterpreter child); + + static String getSetTagForDeferredChildBindings( + JinjavaInterpreter interpreter, + String currentImportAlias, + Map childBindings + ) { + return childBindings + .entrySet() + .stream() + .filter(entry -> + entry.getValue() instanceof DeferredValue && + ((DeferredValue) entry.getValue()).getOriginalValue() != null + ) + .filter(entry -> !interpreter.getContext().containsKey(entry.getKey())) + .filter(entry -> !entry.getKey().equals(currentImportAlias)) + .map(entry -> + EagerReconstructionUtils.buildBlockOrInlineSetTag( // don't register deferred token so that we don't defer them on higher context scopes; they only exist in the child scope + entry.getKey(), + ((DeferredValue) entry.getValue()).getOriginalValue(), + interpreter + ) + ) + .collect(Collectors.joining()); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategyFactory.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategyFactory.java new file mode 100644 index 000000000..1ab5fefc4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/EagerImportingStrategyFactory.java @@ -0,0 +1,37 @@ +package com.hubspot.jinjava.lib.tag.eager.importing; + +import com.google.common.base.Strings; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.ImportTag; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import java.util.List; + +public class EagerImportingStrategyFactory { + + public static ImportingData getImportingData( + TagToken tagToken, + JinjavaInterpreter interpreter + ) { + List helpers = ImportTag.getHelpers(tagToken); + String initialPathSetter = getSetTagForCurrentPath(interpreter); + return new ImportingData(interpreter, tagToken, helpers, initialPathSetter); + } + + public static EagerImportingStrategy create(ImportingData importingData) { + String currentImportAlias = ImportTag.getContextVar(importingData.getHelpers()); + if (Strings.isNullOrEmpty(currentImportAlias)) { + return new FlatEagerImportingStrategy(importingData); + } + return new AliasedEagerImportingStrategy(importingData, currentImportAlias); + } + + public static String getSetTagForCurrentPath(JinjavaInterpreter interpreter) { + return EagerReconstructionUtils.buildBlockOrInlineSetTag( + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + RelativePathResolver.getCurrentPathFromStackOrKey(interpreter), + interpreter + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/FlatEagerImportingStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/FlatEagerImportingStrategy.java new file mode 100644 index 000000000..17b4e4d68 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/FlatEagerImportingStrategy.java @@ -0,0 +1,96 @@ +package com.hubspot.jinjava.lib.tag.eager.importing; + +import com.google.common.annotations.VisibleForTesting; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.tag.ImportTag; +import com.hubspot.jinjava.util.EagerReconstructionUtils; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +public class FlatEagerImportingStrategy implements EagerImportingStrategy { + + private final ImportingData importingData; + + @VisibleForTesting + public FlatEagerImportingStrategy(ImportingData importingData) { + this.importingData = importingData; + } + + @Override + public String handleDeferredTemplateFile(DeferredValueException e) { + throw e; + } + + @Override + public void setup(JinjavaInterpreter child) { + // Do nothing + } + + @Override + public void integrateChild(JinjavaInterpreter child) { + JinjavaInterpreter parent = importingData.getOriginalInterpreter(); + for (MacroFunction macro : child.getContext().getGlobalMacros().values()) { + if (parent.getContext().isDeferredExecutionMode()) { + macro.setDeferred(true); + } + } + for (MacroFunction macro : child.getContext().getGlobalMacros().values()) { + parent.getContext().addGlobalMacro(macro); + } + Map childBindings = child.getContext().getSessionBindings(); + + childBindings.remove(Context.GLOBAL_MACROS_SCOPE_KEY); + childBindings.remove(Context.IMPORT_RESOURCE_ALIAS_KEY); + Map childBindingsWithoutImportResourcePath = + ImportTag.getChildBindingsWithoutImportResourcePath(childBindings); + if (parent.getContext().isDeferredExecutionMode()) { + childBindingsWithoutImportResourcePath + .keySet() + .forEach(key -> + parent + .getContext() + .put(key, DeferredValue.instance(parent.getContext().get(key))) + ); + } else { + parent.getContext().putAll(childBindingsWithoutImportResourcePath); + } + } + + @Override + public String getFinalOutput(String output, JinjavaInterpreter child) { + if (importingData.getOriginalInterpreter().getContext().isDeferredExecutionMode()) { + // defer imported variables + Context context = importingData.getOriginalInterpreter().getContext(); + EagerReconstructionUtils.buildSetTag( + child + .getContext() + .getSessionBindings() + .entrySet() + .stream() + .filter(entry -> + !(entry.getValue() instanceof DeferredValue) && entry.getValue() != null + ) + .filter(entry -> + !MetaContextVariables.isMetaContextVariable(entry.getKey(), context) + ) + .collect(Collectors.toMap(Entry::getKey, entry -> "")), + importingData.getOriginalInterpreter(), + true + ); + } + return ( + EagerImportingStrategy.getSetTagForDeferredChildBindings( + importingData.getOriginalInterpreter(), + null, + child.getContext().getSessionBindings() + ) + + output + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/ImportingData.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/ImportingData.java new file mode 100644 index 000000000..579b3bff6 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/importing/ImportingData.java @@ -0,0 +1,41 @@ +package com.hubspot.jinjava.lib.tag.eager.importing; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.List; + +public class ImportingData { + + private final JinjavaInterpreter originalInterpreter; + private final TagToken tagToken; + private final List helpers; + private final String initialPathSetter; + + public ImportingData( + JinjavaInterpreter originalInterpreter, + TagToken tagToken, + List helpers, + String initialPathSetter + ) { + this.originalInterpreter = originalInterpreter; + this.tagToken = tagToken; + this.helpers = helpers; + this.initialPathSetter = initialPathSetter; + } + + public JinjavaInterpreter getOriginalInterpreter() { + return originalInterpreter; + } + + public TagToken getTagToken() { + return tagToken; + } + + public List getHelpers() { + return helpers; + } + + public String getInitialPathSetter() { + return initialPathSetter; + } +} diff --git a/src/main/java/com/hubspot/jinjava/loader/CascadingResourceLocator.java b/src/main/java/com/hubspot/jinjava/loader/CascadingResourceLocator.java index 5ba55e447..a32b28950 100644 --- a/src/main/java/com/hubspot/jinjava/loader/CascadingResourceLocator.java +++ b/src/main/java/com/hubspot/jinjava/loader/CascadingResourceLocator.java @@ -1,11 +1,10 @@ package com.hubspot.jinjava.loader; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.IOException; import java.nio.charset.Charset; import java.util.Arrays; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - public class CascadingResourceLocator implements ResourceLocator { private Iterable locators; @@ -15,17 +14,19 @@ public CascadingResourceLocator(ResourceLocator... locators) { } @Override - public String getString(String fullName, Charset encoding, - JinjavaInterpreter interpreter) throws IOException { - + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { for (ResourceLocator locator : locators) { try { return locator.getString(fullName, encoding, interpreter); - } catch (ResourceNotFoundException e) { /* */ + } catch (ResourceNotFoundException e) { + /* */ } } throw new ResourceNotFoundException("Couldn't find resource: " + fullName); } - } diff --git a/src/main/java/com/hubspot/jinjava/loader/ClasspathResourceLocator.java b/src/main/java/com/hubspot/jinjava/loader/ClasspathResourceLocator.java index 598713cd6..9989c608e 100644 --- a/src/main/java/com/hubspot/jinjava/loader/ClasspathResourceLocator.java +++ b/src/main/java/com/hubspot/jinjava/loader/ClasspathResourceLocator.java @@ -1,21 +1,22 @@ package com.hubspot.jinjava.loader; -import java.io.IOException; -import java.nio.charset.Charset; - import com.google.common.io.Resources; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.io.IOException; +import java.nio.charset.Charset; public class ClasspathResourceLocator implements ResourceLocator { @Override - public String getString(String fullName, Charset encoding, - JinjavaInterpreter interpreter) throws IOException { + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { try { return Resources.toString(Resources.getResource(fullName), encoding); } catch (IllegalArgumentException e) { throw new ResourceNotFoundException("Couldn't find resource: " + fullName); } } - } diff --git a/src/main/java/com/hubspot/jinjava/loader/FileLocator.java b/src/main/java/com/hubspot/jinjava/loader/FileLocator.java index 0ffd3349b..de02a7173 100644 --- a/src/main/java/com/hubspot/jinjava/loader/FileLocator.java +++ b/src/main/java/com/hubspot/jinjava/loader/FileLocator.java @@ -15,14 +15,13 @@ **********************************************************************/ package com.hubspot.jinjava.loader; +import com.google.common.io.Files; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; -import com.google.common.io.Files; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - public class FileLocator implements ResourceLocator { private File baseDir; @@ -36,7 +35,9 @@ public FileLocator() { public FileLocator(File baseDir) throws FileNotFoundException { if (!baseDir.exists()) { - throw new FileNotFoundException(String.format("Specified baseDir [%s] does not exist", baseDir.getAbsolutePath())); + throw new FileNotFoundException( + String.format("Specified baseDir [%s] does not exist", baseDir.getAbsolutePath()) + ); } this.baseDir = baseDir; } @@ -52,7 +53,8 @@ private File resolveFileName(String name) { } @Override - public String getString(String name, Charset encoding, JinjavaInterpreter interpreter) throws IOException { + public String getString(String name, Charset encoding, JinjavaInterpreter interpreter) + throws IOException { File file = resolveFileName(name); if (!file.exists() || !file.isFile()) { @@ -61,5 +63,4 @@ public String getString(String name, Charset encoding, JinjavaInterpreter interp return Files.toString(file, encoding); } - } diff --git a/src/main/java/com/hubspot/jinjava/loader/LocationResolver.java b/src/main/java/com/hubspot/jinjava/loader/LocationResolver.java new file mode 100644 index 000000000..f511f00b8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/loader/LocationResolver.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.loader; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + +public interface LocationResolver { + String resolve(String path, JinjavaInterpreter interpreter); +} diff --git a/src/main/java/com/hubspot/jinjava/loader/RelativePathResolver.java b/src/main/java/com/hubspot/jinjava/loader/RelativePathResolver.java new file mode 100644 index 000000000..77a4493c3 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/loader/RelativePathResolver.java @@ -0,0 +1,36 @@ +package com.hubspot.jinjava.loader; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class RelativePathResolver implements LocationResolver { + + public static final String CURRENT_PATH_CONTEXT_KEY = "current_path"; + + @Override + public String resolve(String path, JinjavaInterpreter interpreter) { + if (path.startsWith("./") || path.startsWith("../")) { + String parentPath = getCurrentPathFromStackOrKey(interpreter); + + Path templatePath = Paths.get(parentPath); + Path folderPath = templatePath.getParent() != null + ? templatePath.getParent() + : Paths.get(""); + if (folderPath != null) { + return folderPath.resolve(path).normalize().toString(); + } + } + return path; + } + + public static String getCurrentPathFromStackOrKey(JinjavaInterpreter interpreter) { + return interpreter + .getContext() + .getCurrentPathStack() + .peek() + .orElseGet(() -> + (String) interpreter.getContext().getOrDefault(CURRENT_PATH_CONTEXT_KEY, "") + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/loader/ResourceLocator.java b/src/main/java/com/hubspot/jinjava/loader/ResourceLocator.java index c6fdb0771..8bf93affe 100644 --- a/src/main/java/com/hubspot/jinjava/loader/ResourceLocator.java +++ b/src/main/java/com/hubspot/jinjava/loader/ResourceLocator.java @@ -15,13 +15,16 @@ **********************************************************************/ package com.hubspot.jinjava.loader; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.IOException; import java.nio.charset.Charset; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Optional; public interface ResourceLocator { + String getString(String fullName, Charset encoding, JinjavaInterpreter interpreter) + throws IOException; - String getString(String fullName, Charset encoding, JinjavaInterpreter interpreter) throws IOException; - + default Optional getLocationResolver() { + return Optional.empty(); + } } diff --git a/src/main/java/com/hubspot/jinjava/loader/ResourceNotFoundException.java b/src/main/java/com/hubspot/jinjava/loader/ResourceNotFoundException.java index 842be5527..2dbee08b9 100644 --- a/src/main/java/com/hubspot/jinjava/loader/ResourceNotFoundException.java +++ b/src/main/java/com/hubspot/jinjava/loader/ResourceNotFoundException.java @@ -3,10 +3,10 @@ import java.io.IOException; public class ResourceNotFoundException extends IOException { + private static final long serialVersionUID = 1L; public ResourceNotFoundException(String message) { super(message); } - } diff --git a/src/main/java/com/hubspot/jinjava/mode/DefaultExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/DefaultExecutionMode.java new file mode 100644 index 000000000..99f28b6c5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/DefaultExecutionMode.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.mode; + +public class DefaultExecutionMode implements ExecutionMode { + + private static final ExecutionMode INSTANCE = new DefaultExecutionMode(); + + private DefaultExecutionMode() {} + + public static ExecutionMode instance() { + return INSTANCE; + } +} diff --git a/src/main/java/com/hubspot/jinjava/mode/EagerExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/EagerExecutionMode.java new file mode 100644 index 000000000..82a82ad90 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/EagerExecutionMode.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.mode; + +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.lib.expression.EagerExpressionStrategy; +import com.hubspot.jinjava.lib.tag.eager.EagerTagDecorator; +import com.hubspot.jinjava.lib.tag.eager.EagerTagFactory; +import com.hubspot.jinjava.loader.RelativePathResolver; +import java.util.Optional; + +public class EagerExecutionMode implements ExecutionMode { + + private static final ExecutionMode INSTANCE = new EagerExecutionMode(); + + // These meta context variables should never be removed from the set of meta context variables + public static final ImmutableSet STATIC_META_CONTEXT_VARIABLES = + ImmutableSet.of( + Context.GLOBAL_MACROS_SCOPE_KEY, + Context.IMPORT_RESOURCE_PATH_KEY, + Context.DEFERRED_IMPORT_RESOURCE_PATH_KEY, + Context.IMPORT_RESOURCE_ALIAS_KEY, + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY + ); + + protected EagerExecutionMode() {} + + public static ExecutionMode instance() { + return INSTANCE; + } + + @Override + public boolean isPreserveRawTags() { + return true; + } + + @Override + public boolean useEagerParser() { + return true; + } + + @Override + public boolean useEagerContextReverting() { + return true; + } + + @Override + public void prepareContext(Context context) { + context + .getAllTags() + .stream() + .filter(tag -> !(tag instanceof EagerTagDecorator)) + .map(EagerTagFactory::getEagerTagDecorator) + .filter(Optional::isPresent) + .forEach(maybeEagerTag -> context.registerTag(maybeEagerTag.get())); + context.setExpressionStrategy(new EagerExpressionStrategy()); + context.addMetaContextVariables(STATIC_META_CONTEXT_VARIABLES); + } +} diff --git a/src/main/java/com/hubspot/jinjava/mode/ExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/ExecutionMode.java new file mode 100644 index 000000000..83c682519 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/ExecutionMode.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.mode; + +import com.hubspot.jinjava.interpret.Context; + +public interface ExecutionMode { + default boolean isPreserveRawTags() { + return false; + } + + default boolean useEagerParser() { + return false; + } + + /** + * This will determine if the entire context can be reverted or if only the current scope can get reverted. + * A snapshot of the context is created so it is expensive to do so with the entire context, but less expensive + * to only do that with the current scope + * @return whether the entire context (true) or just the current scope (false) will have a snapshot created to + * allow reverting of modified values in deferred execution mode. + */ + default boolean useEagerContextReverting() { + return false; + } + + default void prepareContext(Context context) {} +} diff --git a/src/main/java/com/hubspot/jinjava/mode/NonRevertingEagerExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/NonRevertingEagerExecutionMode.java new file mode 100644 index 000000000..00ccc7d53 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/NonRevertingEagerExecutionMode.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.mode; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class NonRevertingEagerExecutionMode extends EagerExecutionMode { + + private static final ExecutionMode INSTANCE = new NonRevertingEagerExecutionMode(); + + protected NonRevertingEagerExecutionMode() {} + + @SuppressFBWarnings( + value = "HSM_HIDING_METHOD", + justification = "Purposefully overriding to return static instance of this class." + ) + public static ExecutionMode instance() { + return INSTANCE; + } + + @Override + public boolean useEagerContextReverting() { + return false; + } +} diff --git a/src/main/java/com/hubspot/jinjava/mode/PreserveRawExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/PreserveRawExecutionMode.java new file mode 100644 index 000000000..152cf67dd --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/PreserveRawExecutionMode.java @@ -0,0 +1,17 @@ +package com.hubspot.jinjava.mode; + +public class PreserveRawExecutionMode implements ExecutionMode { + + private static final ExecutionMode INSTANCE = new PreserveRawExecutionMode(); + + private PreserveRawExecutionMode() {} + + public static ExecutionMode instance() { + return INSTANCE; + } + + @Override + public boolean isPreserveRawTags() { + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/DummyObject.java b/src/main/java/com/hubspot/jinjava/objects/DummyObject.java new file mode 100644 index 000000000..3d082cea7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/DummyObject.java @@ -0,0 +1,66 @@ +package com.hubspot.jinjava.objects; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +public class DummyObject implements Map, PyWrapper { + + @Override + public int size() { + return 1; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean containsKey(Object key) { + return true; + } + + @Override + public boolean containsValue(Object value) { + return true; + } + + @Override + public Object get(Object key) { + return new DummyObject(); + } + + @Override + public Object put(Object key, Object value) { + return new DummyObject(); + } + + @Override + public Object remove(Object key) { + return new DummyObject(); + } + + @Override + public void putAll(Map m) {} + + @Override + public void clear() {} + + @Override + public Set keySet() { + return ImmutableSet.of(new DummyObject()); + } + + @Override + public Collection values() { + return ImmutableList.of(new DummyObject()); + } + + @Override + public Set> entrySet() { + return ImmutableSet.of(Map.entry(new DummyObject(), new DummyObject())); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/Namespace.java b/src/main/java/com/hubspot/jinjava/objects/Namespace.java new file mode 100644 index 000000000..4069cf722 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/Namespace.java @@ -0,0 +1,31 @@ +package com.hubspot.jinjava.objects; + +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class Namespace extends SizeLimitingPyMap implements PyishSerializable { + + public Namespace() { + this(new HashMap<>()); + } + + public Namespace(Map map) { + this(map, Integer.MAX_VALUE); + } + + public Namespace(Map map, int maxSize) { + super(map, maxSize); + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) PyishSerializable.super + .appendPyishString((T) appendable.append("namespace(")) + .append(')'); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/PyWrapper.java b/src/main/java/com/hubspot/jinjava/objects/PyWrapper.java index cd4f8fe06..1ba7d553c 100644 --- a/src/main/java/com/hubspot/jinjava/objects/PyWrapper.java +++ b/src/main/java/com/hubspot/jinjava/objects/PyWrapper.java @@ -1,10 +1,10 @@ package com.hubspot.jinjava.objects; /** - * Marker indicating an object is a python wrapper interface + * Marker indicating an object is a python wrapper interface. + * + * Use this on classes which you don't want automatically wrapped into Py* forms (i.e. List types into PyList) * * @author jstehler */ -public interface PyWrapper { - -} +public interface PyWrapper {} diff --git a/src/main/java/com/hubspot/jinjava/objects/SafeString.java b/src/main/java/com/hubspot/jinjava/objects/SafeString.java new file mode 100644 index 000000000..2d2d0fc58 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/SafeString.java @@ -0,0 +1,22 @@ +package com.hubspot.jinjava.objects; + +import com.fasterxml.jackson.annotation.JsonValue; + +public class SafeString { + + private final String value; + + public SafeString(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/ArrayBacked.java b/src/main/java/com/hubspot/jinjava/objects/collections/ArrayBacked.java new file mode 100644 index 000000000..05937c1ed --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/ArrayBacked.java @@ -0,0 +1,5 @@ +package com.hubspot.jinjava.objects.collections; + +public interface ArrayBacked { + Object backingArray(); +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/PyList.java b/src/main/java/com/hubspot/jinjava/objects/collections/PyList.java index f92d44455..5658f6a0d 100644 --- a/src/main/java/com/hubspot/jinjava/objects/collections/PyList.java +++ b/src/main/java/com/hubspot/jinjava/objects/collections/PyList.java @@ -1,13 +1,17 @@ package com.hubspot.jinjava.objects.collections; -import java.util.List; - import com.google.common.collect.ForwardingList; +import com.hubspot.jinjava.interpret.IndexOutOfRangeException; import com.hubspot.jinjava.objects.PyWrapper; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; public class PyList extends ForwardingList implements PyWrapper { - private List list; + private boolean computingHashCode = false; + private final List list; public PyList(List list) { this.list = list; @@ -23,7 +27,103 @@ public List toList() { } public boolean append(Object e) { + if (this == e) { + return false; + } return add(e); } + public void insert(int i, Object e) { + if (this == e) { + return; + } + if (i >= list.size()) { + throw createOutOfRangeException(i); + } + + if (i < 0) { + i = Math.max(0, list.size() + i); + } + + add(i, e); + } + + public boolean extend(PyList e) { + return e != null && addAll(e.list); + } + + public Object pop() { + if (list.size() == 0) { + throw createOutOfRangeException(0); + } + return remove(list.size() - 1); + } + + public Object pop(int index) { + if (Math.abs(index) >= list.size()) { + throw createOutOfRangeException(index); + } + + if (index < 0) { + index = list.size() + index; + } + + return remove(index); + } + + public long count(Object o) { + return stream().filter(object -> Objects.equals(object, o)).count(); + } + + public void reverse() { + Collections.reverse(list); + } + + public PyList copy() { + return new PyList(new ArrayList<>(list)); + } + + public int index(Object o) { + return indexOf(o); + } + + @Override + public Object get(int index) { + if (index < 0 || index >= list.size()) { + throw createOutOfRangeException(index); + } + return super.get(index); + } + + public int index(Object o, int begin, int end) { + for (int i = begin; i < end; i++) { + if (Objects.equals(o, get(i))) { + return i; + } + } + return -1; + } + + IndexOutOfRangeException createOutOfRangeException(int index) { + return new IndexOutOfRangeException( + String.format("Index %d is out of range for list of size %d", index, list.size()) + ); + } + + /** + * This is not thread-safe + * @return hashCode, preventing recursion + */ + @Override + public int hashCode() { + if (computingHashCode) { + return Objects.hashCode(null); + } + try { + computingHashCode = true; + return super.hashCode(); + } finally { + computingHashCode = false; + } + } } diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/PyMap.java b/src/main/java/com/hubspot/jinjava/objects/collections/PyMap.java index 927187c01..c885dfd70 100644 --- a/src/main/java/com/hubspot/jinjava/objects/collections/PyMap.java +++ b/src/main/java/com/hubspot/jinjava/objects/collections/PyMap.java @@ -1,14 +1,16 @@ package com.hubspot.jinjava.objects.collections; -import java.util.Map; -import java.util.Set; - import com.google.common.collect.ForwardingMap; import com.hubspot.jinjava.objects.PyWrapper; +import java.util.Map; +import java.util.Objects; +import java.util.Set; public class PyMap extends ForwardingMap implements PyWrapper { - private Map map; + private boolean computingHashCode = false; + + private final Map map; public PyMap(Map map) { this.map = map; @@ -19,6 +21,23 @@ protected Map delegate() { return map; } + public Object get(String key, Object defaultValue) { + return getOrDefault(key, defaultValue); + } + + @Override + public Object put(String s, Object o) { + if (o == this) { + throw new IllegalArgumentException("Can't add map object to itself"); + } + return delegate().put(s, o); + } + + @Override + public String toString() { + return delegate().toString(); + } + public Map toMap() { return map; } @@ -27,8 +46,41 @@ public Set> items() { return entrySet(); } + public Set keys() { + return keySet(); + } + public void update(Map m) { + if (m == this) { + throw new IllegalArgumentException("Can't update map object with itself"); + } putAll(m); } + @Override + public void putAll(Map m) { + if (m == this) { + throw new IllegalArgumentException( + "Map putAll() operation can't be used to add map to itself" + ); + } + super.putAll(m); + } + + /** + * This is not thread-safe + * @return hashCode, preventing recursion + */ + @Override + public int hashCode() { + if (computingHashCode) { + return Objects.hashCode(null); + } + try { + computingHashCode = true; + return super.hashCode(); + } finally { + computingHashCode = false; + } + } } diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/PySet.java b/src/main/java/com/hubspot/jinjava/objects/collections/PySet.java new file mode 100644 index 000000000..6c92560e2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/PySet.java @@ -0,0 +1,38 @@ +package com.hubspot.jinjava.objects.collections; + +import com.google.common.collect.ForwardingSet; +import com.hubspot.jinjava.objects.PyWrapper; +import java.util.Objects; +import java.util.Set; + +public class PySet extends ForwardingSet implements PyWrapper { + + private boolean computingHashCode = false; + private final Set set; + + public PySet(Set set) { + this.set = set; + } + + @Override + protected Set delegate() { + return set; + } + + /** + * This is not thread-safe + * @return hashCode, preventing recursion + */ + @Override + public int hashCode() { + if (computingHashCode) { + return Objects.hashCode(null); + } + try { + computingHashCode = true; + return super.hashCode(); + } finally { + computingHashCode = false; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyList.java b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyList.java new file mode 100644 index 000000000..ef2830631 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyList.java @@ -0,0 +1,93 @@ +package com.hubspot.jinjava.objects.collections; + +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.objects.PyWrapper; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.Nonnull; + +public class SizeLimitingPyList extends PyList implements PyWrapper { + + private int maxSize; + private boolean hasWarned; + + private SizeLimitingPyList(List list) { + super(list); + } + + public SizeLimitingPyList(List list, int maxSize) { + super(list); + if (list == null) { + throw new IllegalArgumentException("list is null"); + } + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize must be >= 1"); + } + + this.maxSize = maxSize; + if (list.size() > maxSize) { + throw new CollectionTooBigException(list.size(), maxSize); + } + } + + @Override + public boolean add(Object element) { + checkSize(size() + 1); + return super.add(element); + } + + @Override + public void add(int index, Object element) { + checkSize(size() + 1); + super.add(index, element); + } + + @Override + public boolean addAll(int index, @Nonnull Collection elements) { + if (elements == null || elements.isEmpty()) { + return false; + } + checkSize(size() + elements.size()); + return super.addAll(index, elements); + } + + @Override + public boolean addAll(@Nonnull Collection elements) { + if (elements == null || elements.isEmpty()) { + return false; + } + checkSize(size() + elements.size()); + return super.addAll(elements); + } + + @Override + public PyList copy() { + return new SizeLimitingPyList(new ArrayList<>(delegate())); + } + + private void checkSize(int newSize) { + if (newSize > maxSize) { + throw new CollectionTooBigException(newSize, maxSize); + } else if (!hasWarned && newSize >= maxSize * 0.9) { + hasWarned = true; + JinjavaInterpreter + .getCurrent() + .addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.COLLECTION_TOO_BIG, + String.format("List is at 90%% of max size (%d of %d)", newSize, maxSize), + null, + -1, + -1, + new CollectionTooBigException(newSize, maxSize) + ) + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMap.java b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMap.java new file mode 100644 index 000000000..e4c7b0b16 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMap.java @@ -0,0 +1,75 @@ +package com.hubspot.jinjava.objects.collections; + +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.objects.PyWrapper; +import java.util.HashSet; +import java.util.Map; + +public class SizeLimitingPyMap extends PyMap implements PyWrapper { + + private int maxSize; + private boolean hasWarned; + + private SizeLimitingPyMap(Map map) { + super(map); + } + + public SizeLimitingPyMap(Map map, int maxSize) { + super(map); + if (map == null) { + throw new IllegalArgumentException("map is null"); + } + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize must be >= 1"); + } + + this.maxSize = maxSize; + checkSize(map.size()); + } + + @Override + public Object put(String s, Object o) { + if (!delegate().containsKey(s)) { + checkSize(delegate().size() + 1); + } + + return super.put(s, o); + } + + @Override + public void putAll(Map m) { + if (m == null) { + return; + } + HashSet keys = new HashSet<>(delegate().keySet()); + checkSize( + (int) m.keySet().stream().filter(k -> !keys.contains(k)).count() + delegate().size() + ); + super.putAll(m); + } + + private void checkSize(int newSize) { + if (newSize > maxSize) { + throw new CollectionTooBigException(newSize, maxSize); + } else if (!hasWarned && newSize >= maxSize * 0.9) { + hasWarned = true; + JinjavaInterpreter + .getCurrent() + .addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.COLLECTION_TOO_BIG, + String.format("Map is at 90%% of max size (%d of %d)", newSize, maxSize), + null, + -1, + -1, + new CollectionTooBigException(newSize, maxSize) + ) + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPySet.java b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPySet.java new file mode 100644 index 000000000..b3c25a2fe --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/SizeLimitingPySet.java @@ -0,0 +1,68 @@ +package com.hubspot.jinjava.objects.collections; + +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.objects.PyWrapper; +import java.util.Collection; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nonnull; + +public class SizeLimitingPySet extends PySet implements PyWrapper { + + private int maxSize; + private boolean hasWarned; + + public SizeLimitingPySet(Set set, int maxSize) { + super(Objects.requireNonNull(set, "set is null")); + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize must be >= 1"); + } + + this.maxSize = maxSize; + if (set.size() > maxSize) { + throw new CollectionTooBigException(set.size(), maxSize); + } + } + + @Override + public boolean add(Object element) { + checkSize(size() + 1); + return super.add(element); + } + + @Override + public boolean addAll(@Nonnull Collection elements) { + if (elements == null || elements.isEmpty()) { + return false; + } + checkSize(size() + elements.size()); + return super.addAll(elements); + } + + private void checkSize(int newSize) { + if (newSize > maxSize) { + throw new CollectionTooBigException(newSize, maxSize); + } else if (!hasWarned && newSize >= maxSize * 0.9) { + hasWarned = true; + JinjavaInterpreter current = JinjavaInterpreter.getCurrent(); + if (current == null) { + return; + } + current.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.COLLECTION_TOO_BIG, + String.format("Set is at 90%% of max size (%d of %d)", newSize, maxSize), + null, + -1, + -1, + new CollectionTooBigException(newSize, maxSize) + ) + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/collections/SnakeCaseAccessibleMap.java b/src/main/java/com/hubspot/jinjava/objects/collections/SnakeCaseAccessibleMap.java new file mode 100644 index 000000000..1c4b68c90 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/collections/SnakeCaseAccessibleMap.java @@ -0,0 +1,43 @@ +package com.hubspot.jinjava.objects.collections; + +import com.google.common.base.CaseFormat; +import com.hubspot.jinjava.lib.filter.AllowSnakeCaseFilter; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.Map; + +public class SnakeCaseAccessibleMap extends PyMap implements PyishSerializable { + + public SnakeCaseAccessibleMap(Map map) { + super(map); + } + + @Override + public Object get(Object key) { + Object result = super.get(key); + if (result == null && key instanceof String) { + return getWithCamelCase((String) key); + } + return result; + } + + private Object getWithCamelCase(String key) { + if (key == null) { + return null; + } + if (key.indexOf('_') == -1) { + return null; + } + return super.get(CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, key)); + } + + @SuppressWarnings("unchecked") + @Override + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable + .append(PyishSerializable.writeValueAsString(toMap())) + .append('|') + .append(AllowSnakeCaseFilter.NAME); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/date/CurrentDateTimeProvider.java b/src/main/java/com/hubspot/jinjava/objects/date/CurrentDateTimeProvider.java new file mode 100644 index 000000000..937d4d00a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/date/CurrentDateTimeProvider.java @@ -0,0 +1,9 @@ +package com.hubspot.jinjava.objects.date; + +public class CurrentDateTimeProvider implements DateTimeProvider { + + @Override + public long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/date/DateTimeProvider.java b/src/main/java/com/hubspot/jinjava/objects/date/DateTimeProvider.java new file mode 100644 index 000000000..ca6c53c9e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/date/DateTimeProvider.java @@ -0,0 +1,5 @@ +package com.hubspot.jinjava.objects.date; + +public interface DateTimeProvider { + long getCurrentTimeMillis(); +} diff --git a/src/main/java/com/hubspot/jinjava/objects/date/FixedDateTimeProvider.java b/src/main/java/com/hubspot/jinjava/objects/date/FixedDateTimeProvider.java new file mode 100644 index 000000000..5eae9cabe --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/date/FixedDateTimeProvider.java @@ -0,0 +1,15 @@ +package com.hubspot.jinjava.objects.date; + +public class FixedDateTimeProvider implements DateTimeProvider { + + private long currentTimeMillis; + + public FixedDateTimeProvider(long currentTimeMillis) { + this.currentTimeMillis = currentTimeMillis; + } + + @Override + public long getCurrentTimeMillis() { + return currentTimeMillis; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/date/FormattedDate.java b/src/main/java/com/hubspot/jinjava/objects/date/FormattedDate.java index 11831a3bf..3f31b88bc 100644 --- a/src/main/java/com/hubspot/jinjava/objects/date/FormattedDate.java +++ b/src/main/java/com/hubspot/jinjava/objects/date/FormattedDate.java @@ -25,5 +25,4 @@ public ZonedDateTime getDate() { public String getLanguage() { return language; } - } diff --git a/src/main/java/com/hubspot/jinjava/objects/date/InvalidDateFormatException.java b/src/main/java/com/hubspot/jinjava/objects/date/InvalidDateFormatException.java new file mode 100644 index 000000000..ac09a4cf2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/date/InvalidDateFormatException.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.objects.date; + +public class InvalidDateFormatException extends IllegalArgumentException { + + private static final long serialVersionUID = -1577669116818659228L; + + private final String format; + + public InvalidDateFormatException(String format, Throwable cause) { + super(buildMessage(format), cause); + this.format = format; + } + + public InvalidDateFormatException(String format, String reason) { + super(buildMessage(format) + ": " + reason); + this.format = format; + } + + private static String buildMessage(String format) { + return "Invalid date format '" + format + "'"; + } + + public String getFormat() { + return format; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/date/PyishDate.java b/src/main/java/com/hubspot/jinjava/objects/date/PyishDate.java index 718420308..f7b194dfa 100644 --- a/src/main/java/com/hubspot/jinjava/objects/date/PyishDate.java +++ b/src/main/java/com/hubspot/jinjava/objects/date/PyishDate.java @@ -1,29 +1,55 @@ package com.hubspot.jinjava.objects.date; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.PyWrapper; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; import java.io.Serializable; +import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoField; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAdjuster; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalField; +import java.time.temporal.TemporalQuery; +import java.time.temporal.TemporalUnit; +import java.time.temporal.ValueRange; import java.util.Date; import java.util.Objects; import java.util.Optional; - import org.apache.commons.lang3.math.NumberUtils; -import com.hubspot.jinjava.objects.PyWrapper; - /** * an object which quacks like a python date * * @author jstehler * */ -public final class PyishDate extends Date implements Serializable, PyWrapper { +public final class PyishDate + extends Date + implements Serializable, PyWrapper, PyishSerializable { + private static final long serialVersionUID = 1L; + public static final String PYISH_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; + public static final String FULL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + public static final String PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY = + "Jinjava_PyishDate_Custom_Format_Key"; private final ZonedDateTime date; + private String dateFormat = PYISH_DATE_FORMAT; + public PyishDate(ZonedDateTime dt) { super(dt.toInstant().toEpochMilli()); this.date = dt; @@ -38,8 +64,27 @@ public PyishDate(String publishDateStr) { } public PyishDate(Long epochMillis) { - this(ZonedDateTime.ofInstant(Instant.ofEpochMilli( - Optional.ofNullable(epochMillis).orElseGet(System::currentTimeMillis)), ZoneOffset.UTC)); + this( + ZonedDateTime.ofInstant( + Instant.ofEpochMilli( + Optional + .ofNullable(epochMillis) + .orElseGet(() -> + JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getDateTimeProvider) + .map(DateTimeProvider::getCurrentTimeMillis) + .orElseGet(System::currentTimeMillis) + ) + ), + ZoneOffset.UTC + ) + ); + } + + public PyishDate(Instant instant) { + this(ZonedDateTime.ofInstant(instant, ZoneOffset.UTC)); } public String isoformat() { @@ -50,16 +95,19 @@ public String strftime(String fmt) { return StrftimeFormatter.format(date, fmt); } + @SuppressWarnings("deprecation") @Override public int getYear() { return date.getYear(); } + @SuppressWarnings("deprecation") @Override public int getMonth() { return date.getMonthValue(); } + @SuppressWarnings("deprecation") @Override public int getDay() { return date.getDayOfMonth(); @@ -81,6 +129,250 @@ public int getMicrosecond() { return date.get(ChronoField.MILLI_OF_SECOND); } + // ZonedDateTime delegate methods + + public ZoneId getZone() { + return date.getZone(); + } + + public ZoneOffset getOffset() { + return date.getOffset(); + } + + public ZonedDateTime withZoneSameLocal(ZoneId zone) { + return date.withZoneSameLocal(zone); + } + + public ZonedDateTime withZoneSameInstant(ZoneId zone) { + return date.withZoneSameInstant(zone); + } + + public ZonedDateTime withFixedOffsetZone() { + return date.withFixedOffsetZone(); + } + + public ZonedDateTime withEarlierOffsetAtOverlap() { + return date.withEarlierOffsetAtOverlap(); + } + + public ZonedDateTime withLaterOffsetAtOverlap() { + return date.withLaterOffsetAtOverlap(); + } + + public LocalDateTime toLocalDateTime() { + return date.toLocalDateTime(); + } + + public LocalDate toLocalDate() { + return date.toLocalDate(); + } + + public LocalTime toLocalTime() { + return date.toLocalTime(); + } + + public OffsetDateTime toOffsetDateTime() { + return date.toOffsetDateTime(); + } + + @Override + public Instant toInstant() { + return date.toInstant(); + } + + public boolean isSupported(TemporalField field) { + return date.isSupported(field); + } + + public boolean isSupported(TemporalUnit unit) { + return date.isSupported(unit); + } + + public ValueRange range(TemporalField field) { + return date.range(field); + } + + public int get(TemporalField field) { + return date.get(field); + } + + public long getLong(TemporalField field) { + return date.getLong(field); + } + + public int getMonthValue() { + return date.getMonthValue(); + } + + public int getDayOfMonth() { + return date.getDayOfMonth(); + } + + public int getDayOfYear() { + return date.getDayOfYear(); + } + + public DayOfWeek getDayOfWeek() { + return date.getDayOfWeek(); + } + + public int getNano() { + return date.getNano(); + } + + public ZonedDateTime with(TemporalAdjuster adjuster) { + return date.with(adjuster); + } + + public ZonedDateTime with(TemporalField field, long newValue) { + return date.with(field, newValue); + } + + public ZonedDateTime withYear(int year) { + return date.withYear(year); + } + + public ZonedDateTime withMonth(int month) { + return date.withMonth(month); + } + + public ZonedDateTime withDayOfMonth(int dayOfMonth) { + return date.withDayOfMonth(dayOfMonth); + } + + public ZonedDateTime withDayOfYear(int dayOfYear) { + return date.withDayOfYear(dayOfYear); + } + + public ZonedDateTime withHour(int hour) { + return date.withHour(hour); + } + + public ZonedDateTime withMinute(int minute) { + return date.withMinute(minute); + } + + public ZonedDateTime withSecond(int second) { + return date.withSecond(second); + } + + public ZonedDateTime withNano(int nanoOfSecond) { + return date.withNano(nanoOfSecond); + } + + public ZonedDateTime truncatedTo(TemporalUnit unit) { + return date.truncatedTo(unit); + } + + public ZonedDateTime plus(TemporalAmount amountToAdd) { + return date.plus(amountToAdd); + } + + public ZonedDateTime plus(long amountToAdd, TemporalUnit unit) { + return date.plus(amountToAdd, unit); + } + + public ZonedDateTime plusYears(long years) { + return date.plusYears(years); + } + + public ZonedDateTime plusMonths(long months) { + return date.plusMonths(months); + } + + public ZonedDateTime plusWeeks(long weeks) { + return date.plusWeeks(weeks); + } + + public ZonedDateTime plusDays(long days) { + return date.plusDays(days); + } + + public ZonedDateTime plusHours(long hours) { + return date.plusHours(hours); + } + + public ZonedDateTime plusMinutes(long minutes) { + return date.plusMinutes(minutes); + } + + public ZonedDateTime plusSeconds(long seconds) { + return date.plusSeconds(seconds); + } + + public ZonedDateTime plusNanos(long nanos) { + return date.plusNanos(nanos); + } + + public ZonedDateTime minus(TemporalAmount amountToSubtract) { + return date.minus(amountToSubtract); + } + + public ZonedDateTime minus(long amountToSubtract, TemporalUnit unit) { + return date.minus(amountToSubtract, unit); + } + + public ZonedDateTime minusYears(long years) { + return date.minusYears(years); + } + + public ZonedDateTime minusMonths(long months) { + return date.minusMonths(months); + } + + public ZonedDateTime minusWeeks(long weeks) { + return date.minusWeeks(weeks); + } + + public ZonedDateTime minusDays(long days) { + return date.minusDays(days); + } + + public ZonedDateTime minusHours(long hours) { + return date.minusHours(hours); + } + + public ZonedDateTime minusMinutes(long minutes) { + return date.minusMinutes(minutes); + } + + public ZonedDateTime minusSeconds(long seconds) { + return date.minusSeconds(seconds); + } + + public ZonedDateTime minusNanos(long nanos) { + return date.minusNanos(nanos); + } + + public R query(TemporalQuery query) { + return date.query(query); + } + + public long until(Temporal endExclusive, TemporalUnit unit) { + return date.until(endExclusive, unit); + } + + public String format(DateTimeFormatter formatter) { + return date.format(formatter); + } + + public long toEpochSecond() { + return date.toEpochSecond(); + } + + public String getDateFormat() { + return dateFormat; + } + + public void setDateFormat(String dateFormat) { + this.dateFormat = dateFormat; + } + + public PyishDate withDateFormat(String dateFormat) { + setDateFormat(dateFormat); + return this; + } + public Date toDate() { return Date.from(date.toInstant()); } @@ -91,7 +383,23 @@ public ZonedDateTime toDateTime() { @Override public String toString() { - return strftime("yyyy-MM-dd HH:mm:ss"); + if ( + JinjavaInterpreter.getCurrent() != null && + JinjavaInterpreter + .getCurrent() + .getContext() + .containsKey(PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY) + ) { + return strftime( + JinjavaInterpreter + .getCurrent() + .getContext() + .get(PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY) + .toString() + ); + } + + return strftime(dateFormat); } @Override @@ -111,4 +419,17 @@ public boolean equals(Object obj) { return Objects.equals(toDateTime(), that.toDateTime()); } + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable + .append("('") + .append(strftime(FULL_DATE_FORMAT)) + .append("'|strtotime(") + .append(PyishObjectMapper.getAsPyishStringOrThrow(FULL_DATE_FORMAT)) + .append(")).withDateFormat(") + .append(PyishObjectMapper.getAsPyishStringOrThrow(dateFormat)) + .append(')'); + } } diff --git a/src/main/java/com/hubspot/jinjava/objects/date/StrftimeFormatter.java b/src/main/java/com/hubspot/jinjava/objects/date/StrftimeFormatter.java index 664320edb..d36229872 100644 --- a/src/main/java/com/hubspot/jinjava/objects/date/StrftimeFormatter.java +++ b/src/main/java/com/hubspot/jinjava/objects/date/StrftimeFormatter.java @@ -1,11 +1,18 @@ package com.hubspot.jinjava.objects.date; +import static com.hubspot.jinjava.objects.date.StrftimeFormatter.ConversionComponent.localized; +import static com.hubspot.jinjava.objects.date.StrftimeFormatter.ConversionComponent.pattern; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.FormatStyle; -import java.util.HashMap; +import java.util.Locale; import java.util.Map; - +import java.util.Optional; import org.apache.commons.lang3.StringUtils; /** @@ -19,120 +26,170 @@ public class StrftimeFormatter { /* * Mapped from http://strftime.org/, http://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html */ - private static final Map CONVERSIONS = new HashMap(); + private static final Map COMPONENTS; + private static final Map NOMINATIVE_COMPONENTS; + static { - CONVERSIONS.put('a', "EEE"); - CONVERSIONS.put('A', "EEEE"); - CONVERSIONS.put('b', "MMM"); - CONVERSIONS.put('B', "MMMM"); - CONVERSIONS.put('c', "EEE MMM dd HH:mm:ss yyyy"); - CONVERSIONS.put('d', "dd"); - CONVERSIONS.put('e', "d"); // The day of the month like with %d, but padded with blank (range 1 through 31). - CONVERSIONS.put('f', "SSSS"); - CONVERSIONS.put('H', "HH"); - CONVERSIONS.put('h', "hh"); - CONVERSIONS.put('I', "hh"); - CONVERSIONS.put('j', "DDD"); - CONVERSIONS.put('k', "H"); // The hour as a decimal number, using a 24-hour clock like %H, but padded with blank (range 0 through 23). - CONVERSIONS.put('l', "h"); // The hour as a decimal number, using a 12-hour clock like %I, but padded with blank (range 1 through 12). - CONVERSIONS.put('m', "MM"); - CONVERSIONS.put('M', "mm"); - CONVERSIONS.put('p', "a"); - CONVERSIONS.put('S', "ss"); - CONVERSIONS.put('U', "ww"); - CONVERSIONS.put('w', "uu"); - CONVERSIONS.put('W', "ww"); - CONVERSIONS.put('x', "MM/dd/yy"); - CONVERSIONS.put('X', "HH:mm:ss"); - CONVERSIONS.put('y', "yy"); - CONVERSIONS.put('Y', "yyyy"); - CONVERSIONS.put('z', "Z"); - CONVERSIONS.put('Z', "ZZZ"); - CONVERSIONS.put('%', "%"); + COMPONENTS = + ImmutableMap + .builder() + .put('a', pattern("EEE")) + .put('A', pattern("EEEE")) + .put('b', pattern("MMM")) + .put('B', pattern("MMMM")) + .put('c', localized(FormatStyle.MEDIUM, FormatStyle.MEDIUM)) + .put('d', pattern("dd")) + .put('e', pattern("d")) // The day of the month like with %d, but padded with blank (range 1 through 31). + .put('f', pattern("SSSSSS")) + .put('H', pattern("HH")) + .put('h', pattern("hh")) + .put('I', pattern("hh")) + .put('j', pattern("DDD")) + .put('k', pattern("H")) // The hour as a decimal number, using a 24-hour clock like %H, but padded with blank (range 0 through 23). + .put('l', pattern("h")) // The hour as a decimal number, using a 12-hour clock like %I, but padded with blank (range 1 through 12). + .put('m', pattern("MM")) + .put('M', pattern("mm")) + .put('p', pattern("a")) + .put('S', pattern("ss")) + .put('U', pattern("ww")) + .put('w', pattern("e")) + .put('W', pattern("ww")) + .put('x', localized(FormatStyle.SHORT, null)) + .put('X', localized(null, FormatStyle.MEDIUM)) + .put('y', pattern("yy")) + .put('Y', pattern("yyyy")) + .put('z', pattern("Z")) + .put('Z', pattern("z")) + .put('%', (builder, stripLeadingZero) -> builder.appendLiteral("%")) + .build(); + + NOMINATIVE_COMPONENTS = + ImmutableMap + .builder() + .put('B', pattern("LLLL")) + .build(); } /** - * Parses a string in python strftime format, returning the equivalent string in java date time format. + * Build a {@link DateTimeFormatter} that matches the given Python strftime pattern. * - * @param strftime - * @return date formatted as string + * @see Python strftime cheatsheet */ - private static String toJavaDateTimeFormat(String strftime) { + public static DateTimeFormatter toDateTimeFormatter(String strftime) { if (!StringUtils.contains(strftime, '%')) { - return replaceL(strftime); + return DateTimeFormatter.ofPattern(strftime); } - StringBuilder result = new StringBuilder(); + DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder(); for (int i = 0; i < strftime.length(); i++) { char c = strftime.charAt(i); - if (c == '%') { - c = strftime.charAt(++i); - boolean stripLeadingZero = false; + if (c != '%' || strftime.length() <= i + 1) { + builder.appendLiteral(c); + continue; + } - if (c == '-') { - stripLeadingZero = true; - c = strftime.charAt(++i); - } + c = strftime.charAt(++i); + boolean stripLeadingZero = false; + Map components = COMPONENTS; - if (stripLeadingZero) { - result.append(CONVERSIONS.get(c).substring(1)); - } - else { - result.append(CONVERSIONS.get(c)); - } - } - else if (Character.isLetter(c)) { - result.append("'"); - while (Character.isLetter(c)) { - result.append(c); - if (++i < strftime.length()) { - c = strftime.charAt(i); - } - else { - c = 0; - } - } - result.append("'"); - --i; // re-consume last char + if (c == '-') { + stripLeadingZero = true; + c = strftime.charAt(++i); } - else { - result.append(c); + + if (c == 'O') { + c = strftime.charAt(++i); + components = NOMINATIVE_COMPONENTS; } + + final char finalChar = c; + + Optional + .ofNullable(components.get(finalChar)) + .orElseThrow(() -> + new InvalidDateFormatException( + strftime, + String.format("unknown format code '%s'", finalChar) + ) + ) + .append(builder, stripLeadingZero); } - return replaceL(result.toString()); + return builder.toFormatter(); } - private static String replaceL(String s) { - return StringUtils.replaceChars(s, 'L', 'M'); - } + private static DateTimeFormatter formatter(String strftime, Locale locale) { + DateTimeFormatter fmt; + + if (strftime == null) { + strftime = ""; + } - public static DateTimeFormatter formatter(String strftime) { switch (strftime.toLowerCase()) { - case "short": - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); - case "medium": - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); - case "long": - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG); - case "full": - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL); - default: - try { - return DateTimeFormatter.ofPattern(toJavaDateTimeFormat(strftime)); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid date format [" + strftime + "]: " + e.getMessage(), e); - } + case "short": + fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); + break; + case "medium": + fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); + break; + case "long": + fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG); + break; + case "full": + fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL); + break; + default: + try { + fmt = toDateTimeFormatter(strftime); + break; + } catch (IllegalArgumentException e) { + throw new InvalidDateFormatException(strftime, e); + } } + + return fmt.withLocale(locale); } public static String format(ZonedDateTime d) { return format(d, DEFAULT_DATE_FORMAT); } + public static String format(ZonedDateTime d, Locale locale) { + return format(d, DEFAULT_DATE_FORMAT, locale); + } + public static String format(ZonedDateTime d, String strftime) { - return formatter(strftime).format(d); + return format( + d, + strftime, + JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .map(JinjavaConfig::getLocale) + .orElse(Locale.ENGLISH) + ); } + public static String format(ZonedDateTime d, String strftime, Locale locale) { + return formatter(strftime, locale).format(d); + } + + interface ConversionComponent { + DateTimeFormatterBuilder append( + DateTimeFormatterBuilder builder, + boolean stripLeadingZero + ); + + static ConversionComponent pattern(String targetPattern) { + return (builder, stripLeadingZero) -> + builder.appendPattern( + stripLeadingZero ? targetPattern.substring(1) : targetPattern + ); + } + + static ConversionComponent localized(FormatStyle dateStyle, FormatStyle timeStyle) { + return (builder, stripLeadingZero) -> builder.appendLocalized(dateStyle, timeStyle); + } + } } diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/BothCasingBeanSerializer.java b/src/main/java/com/hubspot/jinjava/objects/serialization/BothCasingBeanSerializer.java new file mode 100644 index 000000000..ad7e0b5e8 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/BothCasingBeanSerializer.java @@ -0,0 +1,46 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.hubspot.jinjava.lib.filter.AllowSnakeCaseFilter; +import java.io.IOException; + +public class BothCasingBeanSerializer extends JsonSerializer { + + private final JsonSerializer orignalSerializer; + + private BothCasingBeanSerializer(JsonSerializer jsonSerializer) { + this.orignalSerializer = jsonSerializer; + } + + public static BothCasingBeanSerializer wrapping( + JsonSerializer jsonSerializer + ) { + return new BothCasingBeanSerializer<>(jsonSerializer); + } + + @Override + public void serialize( + T value, + JsonGenerator gen, + SerializerProvider serializerProvider + ) throws IOException { + if ( + Boolean.TRUE.equals( + serializerProvider.getAttribute(PyishObjectMapper.ALLOW_SNAKE_CASE_ATTRIBUTE) + ) + ) { + // if it's directly for output, then we don't want to add the additional filter characters, + // as doing so would make the "|allow_snake_case" appear in the final output. + StringBuilder sb = new StringBuilder(); + sb + .append(PyishSerializable.writeValueAsString(value)) + .append('|') + .append(AllowSnakeCaseFilter.NAME); + gen.writeRawValue(sb.toString()); + } else { + orignalSerializer.serialize(value, gen, serializerProvider); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingJsonProcessingException.java b/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingJsonProcessingException.java new file mode 100644 index 000000000..bcd6789e2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingJsonProcessingException.java @@ -0,0 +1,31 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.annotations.Beta; + +@Beta +public class LengthLimitingJsonProcessingException extends JsonProcessingException { + + private final int maxSize; + private final int attemptedSize; + + protected LengthLimitingJsonProcessingException(int maxSize, int attemptedSize) { + super( + String.format( + "Max length of %d chars reached when serializing. %d chars attempted.", + maxSize, + attemptedSize + ) + ); + this.maxSize = maxSize; + this.attemptedSize = attemptedSize; + } + + public int getAttemptedSize() { + return attemptedSize; + } + + public int getMaxSize() { + return maxSize; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingWriter.java b/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingWriter.java new file mode 100644 index 000000000..84f6281a4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/LengthLimitingWriter.java @@ -0,0 +1,72 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.google.common.annotations.Beta; +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.concurrent.atomic.AtomicInteger; + +@Beta +public class LengthLimitingWriter extends Writer { + + public static final String REMAINING_LENGTH_ATTRIBUTE = "remainingLength"; + private final CharArrayWriter charArrayWriter; + private final AtomicInteger remainingLength; + private final int startingLength; + + public LengthLimitingWriter( + CharArrayWriter charArrayWriter, + AtomicInteger remainingLength + ) { + this.charArrayWriter = charArrayWriter; + this.remainingLength = remainingLength; + startingLength = remainingLength.get(); + } + + @Override + public void write(int c) throws LengthLimitingJsonProcessingException { + checkMaxSize(1); + charArrayWriter.write(c); + } + + @Override + public void write(char[] c, int off, int len) + throws LengthLimitingJsonProcessingException { + checkMaxSize(len); + charArrayWriter.write(c, off, len); + } + + @Override + public void write(String str, int off, int len) + throws LengthLimitingJsonProcessingException { + checkMaxSize(len); + charArrayWriter.write(str, off, len); + } + + private void checkMaxSize(int extra) throws LengthLimitingJsonProcessingException { + if (remainingLength.addAndGet(extra * -1) < 0) { + throw new LengthLimitingJsonProcessingException( + startingLength, + charArrayWriter.size() + extra + ); + } + } + + public char[] toCharArray() { + return charArrayWriter.toCharArray(); + } + + public int size() { + return charArrayWriter.size(); + } + + public String toString() { + return charArrayWriter.toString(); + } + + @Override + public void flush() throws IOException {} + + @Override + public void close() throws IOException {} +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/MapEntrySerializer.java b/src/main/java/com/hubspot/jinjava/objects/serialization/MapEntrySerializer.java new file mode 100644 index 000000000..69c90aa68 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/MapEntrySerializer.java @@ -0,0 +1,54 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.google.common.annotations.Beta; +import java.io.CharArrayWriter; +import java.io.IOException; +import java.util.Map.Entry; +import java.util.concurrent.atomic.AtomicInteger; + +@Beta +public class MapEntrySerializer extends JsonSerializer> { + + public static final MapEntrySerializer INSTANCE = new MapEntrySerializer(); + + private MapEntrySerializer() {} + + @Override + public void serialize( + Entry entry, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider + ) throws IOException { + AtomicInteger remainingLength = (AtomicInteger) serializerProvider.getAttribute( + LengthLimitingWriter.REMAINING_LENGTH_ATTRIBUTE + ); + String key; + String value; + ObjectWriter objectWriter = PyishObjectMapper.PYISH_OBJECT_WRITER.withAttribute( + PyishObjectMapper.ALLOW_SNAKE_CASE_ATTRIBUTE, + serializerProvider.getAttribute(PyishObjectMapper.ALLOW_SNAKE_CASE_ATTRIBUTE) + ); + if (remainingLength != null) { + objectWriter = + objectWriter.withAttribute( + LengthLimitingWriter.REMAINING_LENGTH_ATTRIBUTE, + remainingLength + ); + key = objectWriter.writeValueAsString(entry.getKey()); + LengthLimitingWriter lengthLimitingWriter = new LengthLimitingWriter( + new CharArrayWriter(), + remainingLength + ); + objectWriter.writeValue(lengthLimitingWriter, entry.getValue()); + value = lengthLimitingWriter.toString(); + } else { + key = objectWriter.writeValueAsString(entry.getKey()); + value = objectWriter.writeValueAsString(entry.getValue()); + } + jsonGenerator.writeRawValue(String.format("fn:map_entry(%s, %s)", key, value)); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBeanSerializerModifier.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBeanSerializerModifier.java new file mode 100644 index 000000000..255c34cdf --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBeanSerializerModifier.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.ser.BeanSerializer; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import com.google.common.annotations.Beta; +import java.util.Map; + +@Beta +public class PyishBeanSerializerModifier extends BeanSerializerModifier { + + public static final PyishBeanSerializerModifier INSTANCE = + new PyishBeanSerializerModifier(); + + private PyishBeanSerializerModifier() {} + + @Override + public JsonSerializer modifySerializer( + SerializationConfig config, + BeanDescription beanDesc, + JsonSerializer serializer + ) { + // Use the PyishSerializer if it extends the PyishSerializable class. + // For example, a Map implementation could then have custom string serialization. + if (!(PyishSerializable.class.isAssignableFrom(beanDesc.getBeanClass()))) { + if (Map.Entry.class.isAssignableFrom(beanDesc.getBeanClass())) { + return MapEntrySerializer.INSTANCE; + } + if (serializer instanceof BeanSerializer) { + return BothCasingBeanSerializer.wrapping(serializer); + } + return serializer; + } else { + return PyishSerializer.INSTANCE; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBlockSetSerializable.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBlockSetSerializable.java new file mode 100644 index 000000000..e7d52e677 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishBlockSetSerializable.java @@ -0,0 +1,8 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.google.common.annotations.Beta; + +@Beta +public interface PyishBlockSetSerializable { + String getBlockSetBody(); +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishCharacterEscapes.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishCharacterEscapes.java new file mode 100644 index 000000000..f7207ec1d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishCharacterEscapes.java @@ -0,0 +1,37 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.SerializableString; +import com.fasterxml.jackson.core.io.CharacterEscapes; +import com.fasterxml.jackson.core.io.SerializedString; +import com.google.common.annotations.Beta; +import java.util.Arrays; + +@Beta +public class PyishCharacterEscapes extends CharacterEscapes { + + public static final PyishCharacterEscapes INSTANCE = new PyishCharacterEscapes(); + private final int[] asciiEscapes; + + private PyishCharacterEscapes() { + int[] escapes = CharacterEscapes.standardAsciiEscapesForJSON(); + escapes['\n'] = CharacterEscapes.ESCAPE_NONE; + escapes['\t'] = CharacterEscapes.ESCAPE_NONE; + escapes['\r'] = CharacterEscapes.ESCAPE_NONE; + escapes['\f'] = CharacterEscapes.ESCAPE_NONE; + escapes['\''] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes = escapes; + } + + @Override + public int[] getEscapeCodesForAscii() { + return Arrays.copyOf(asciiEscapes, asciiEscapes.length); + } + + @Override + public SerializableString getEscapeSequence(int ch) { + if (ch == '\'') { + return new SerializedString("\\'"); + } + return null; + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapper.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapper.java new file mode 100644 index 000000000..551ba77d5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapper.java @@ -0,0 +1,160 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonFactoryBuilder; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.util.WhitespaceUtils; +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +@Beta +public class PyishObjectMapper { + + public static final ObjectWriter PYISH_OBJECT_WRITER; + public static final ObjectWriter SNAKE_CASE_PYISH_OBJECT_WRITER; + public static final String ALLOW_SNAKE_CASE_ATTRIBUTE = "allowSnakeCase"; + + static { + PYISH_OBJECT_WRITER = + getPyishObjectMapper() + .writer(PyishPrettyPrinter.INSTANCE) + .with(PyishCharacterEscapes.INSTANCE); + + SNAKE_CASE_PYISH_OBJECT_WRITER = + getPyishObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .writer(PyishPrettyPrinter.INSTANCE) + .with(PyishCharacterEscapes.INSTANCE); + } + + private static ObjectMapper getPyishObjectMapper() { + ObjectMapper mapper = new ObjectMapper( + new JsonFactoryBuilder().quoteChar('\'').build() + ) + .registerModule(new Jdk8Module()) + .registerModule( + new SimpleModule() + .setSerializerModifier(PyishBeanSerializerModifier.INSTANCE) + .addSerializer(PyishSerializable.class, PyishSerializer.INSTANCE) + ); + mapper.getSerializerProvider().setNullKeySerializer(new NullKeySerializer()); + return mapper; + } + + public static String getAsUnquotedPyishString(Object val) { + if (val == null) { + return ""; + } + if (val instanceof String || val instanceof Number || val instanceof Boolean) { + return val.toString(); + } + return WhitespaceUtils.unquoteAndUnescape(getAsPyishString(val, true)); + } + + public static String getAsPyishString(Object val) { + return getAsPyishString(val, false); + } + + private static String getAsPyishString(Object val, boolean forOutput) { + try { + return getAsPyishStringOrThrow(val, forOutput); + } catch (IOException e) { + handleLengthLimitingException(e); + handleDeferredValueException(e); + return Objects.toString(val, ""); + } + } + + private static void handleDeferredValueException(IOException e) { + Throwable unwrapped = e; + if (e instanceof JsonMappingException) { + unwrapped = unwrapped.getCause(); + } + if (unwrapped instanceof DeferredValueException) { + throw (DeferredValueException) unwrapped; + } + } + + public static void handleLengthLimitingException(IOException e) { + Throwable unwrapped = e; + if (e instanceof JsonMappingException) { + unwrapped = unwrapped.getCause(); + } + if (unwrapped instanceof LengthLimitingJsonProcessingException) { + throw new OutputTooBigException( + ((LengthLimitingJsonProcessingException) unwrapped).getMaxSize(), + ((LengthLimitingJsonProcessingException) unwrapped).getAttemptedSize() + ); + } else if (unwrapped instanceof OutputTooBigException) { + throw (OutputTooBigException) unwrapped; + } + } + + public static String getAsPyishStringOrThrow(Object val) throws IOException { + return getAsPyishStringOrThrow(val, false); + } + + public static String getAsPyishStringOrThrow(Object val, boolean forOutput) + throws IOException { + boolean useSnakeCaseMappingOverride = JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> + interpreter.getConfig().getLegacyOverrides().isUseSnakeCasePropertyNaming() + ) + .orElse(false); + ObjectWriter objectWriter = useSnakeCaseMappingOverride + ? SNAKE_CASE_PYISH_OBJECT_WRITER + : PYISH_OBJECT_WRITER; + Writer writer; + Optional maxOutputSize = JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getConfig().getMaxOutputSize()) + .filter(max -> max > 0); + if (maxOutputSize.isPresent()) { + AtomicInteger remainingLength = new AtomicInteger( + (int) Math.min(Integer.MAX_VALUE, maxOutputSize.get()) + ); + objectWriter = + objectWriter.withAttribute( + LengthLimitingWriter.REMAINING_LENGTH_ATTRIBUTE, + remainingLength + ); + writer = new LengthLimitingWriter(new CharArrayWriter(), remainingLength); + } else { + writer = new CharArrayWriter(); + } + if (!useSnakeCaseMappingOverride) { + objectWriter = objectWriter.withAttribute(ALLOW_SNAKE_CASE_ATTRIBUTE, !forOutput); + } + objectWriter.writeValue(writer, val); + + return writer.toString(); + } + + public static class NullKeySerializer extends JsonSerializer { + + @Override + public void serialize( + Object o, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider + ) throws IOException { + jsonGenerator.writeFieldName(""); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishPrettyPrinter.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishPrettyPrinter.java new file mode 100644 index 000000000..b74ab5afe --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishPrettyPrinter.java @@ -0,0 +1,49 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.google.common.annotations.Beta; +import java.io.IOException; + +@Beta +public class PyishPrettyPrinter extends DefaultPrettyPrinter { + + public static final PyishPrettyPrinter INSTANCE = new PyishPrettyPrinter(); + + @Override + public DefaultPrettyPrinter createInstance() { + return INSTANCE; + } + + private PyishPrettyPrinter() { + _objectIndenter = FixedSpaceIndenter.instance; + _spacesInObjectEntries = false; + } + + @Override + public void beforeArrayValues(JsonGenerator jg) {} + + @Override + public void writeEndArray(JsonGenerator jg, int nrOfValues) throws IOException { + if (!this._arrayIndenter.isInline()) { + --this._nesting; + } + jg.writeRaw(']'); + } + + @Override + public void writeObjectFieldValueSeparator(JsonGenerator jg) throws IOException { + jg.writeRaw(": "); + } + + @Override + public void beforeObjectEntries(JsonGenerator jg) {} + + @Override + public void writeEndObject(JsonGenerator jg, int nrOfEntries) throws IOException { + if (!this._objectIndenter.isInline()) { + --this._nesting; + } + jg.writeRaw("} "); + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializable.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializable.java new file mode 100644 index 000000000..da9dd29c5 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializable.java @@ -0,0 +1,84 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonFactoryBuilder; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.objects.PyWrapper; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +@Beta +public interface PyishSerializable extends PyWrapper { + ObjectWriter SELF_WRITER = new ObjectMapper( + new JsonFactoryBuilder().quoteChar('\'').build() + ) + .registerModule(new Jdk8Module()) + .writer(PyishPrettyPrinter.INSTANCE) + .with(PyishCharacterEscapes.INSTANCE); + + /** + * Allows for a class to append the custom string representation in Jinjava. + * This method will be used by {@link #writePyishSelf(JsonGenerator, SerializerProvider)} + * to specify what will be written to the json generator. + *

+ * @param appendable Appendable to append the pyish string representation to. + * @return The same appendable with an appended result + */ + @SuppressWarnings("unchecked") + default T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(writeValueAsString(this)); + } + + /** + * Allows for a class to specify how its pyish string representation will + * be written to the json generator. + *

+ * If the object's serialization can be broken up into multiple jsonGenerator writes, + * then this method can be overridden to do so instead of a single call to + * {@link JsonGenerator#writeRawValue(String)}. + * @param jsonGenerator The JsonGenerator to write to. + * @param serializerProvider Provides default value serialization and attributes stored on the ObjectWriter if needed. + */ + default void writePyishSelf( + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider + ) throws IOException { + AtomicInteger remainingLength = (AtomicInteger) serializerProvider.getAttribute( + LengthLimitingWriter.REMAINING_LENGTH_ATTRIBUTE + ); + jsonGenerator.writeRawValue( + appendPyishString( + remainingLength == null + ? new StringBuilder() + : new LengthLimitingStringBuilder(remainingLength.get()) + ) + .toString() + ); + } + + /** + * Utility method to assist implementations of PyishSerializable in + * overriding toPyishString(). + * @param value Value to write to a string + * @return Pyish string value in JSON format + */ + static String writeValueAsString(Object value) { + try { + return SELF_WRITER.writeValueAsString(value); + } catch (JsonProcessingException e) { + if (e.getCause() instanceof DeferredValueException) { + throw (DeferredValueException) e.getCause(); + } + return '\'' + Objects.toString(value) + '\''; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializer.java b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializer.java new file mode 100644 index 000000000..f4c7a9b6d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/objects/serialization/PyishSerializer.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.objects.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.io.IOException; +import java.util.Objects; + +@Beta +public class PyishSerializer extends JsonSerializer { + + public static final PyishSerializer INSTANCE = new PyishSerializer(); + + private PyishSerializer() {} + + public void serialize( + Object object, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider + ) throws IOException { + jsonGenerator.setPrettyPrinter(PyishPrettyPrinter.INSTANCE); + jsonGenerator.setCharacterEscapes(PyishCharacterEscapes.INSTANCE); + String string; + Object wrappedObject = JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.wrap(object)) + .orElse(object); + if (wrappedObject instanceof PyishSerializable) { + ((PyishSerializable) wrappedObject).writePyishSelf( + jsonGenerator, + serializerProvider + ); + } else if (wrappedObject instanceof Boolean) { + jsonGenerator.writeBoolean((Boolean) wrappedObject); + } else if (wrappedObject instanceof Number) { + jsonGenerator.writeNumber(wrappedObject.toString()); + } else if (wrappedObject instanceof String) { + jsonGenerator.writeString((String) wrappedObject); + } else { + string = Objects.toString(wrappedObject, ""); + try { + double number = Double.parseDouble(string); + if ( + string.equals(String.valueOf(number)) || + string.equals(String.valueOf((long) number)) + ) { + jsonGenerator.writeNumber(string); + } else { + jsonGenerator.writeString(string); + } + } catch (NumberFormatException e) { + if ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string)) { + jsonGenerator.writeBoolean(Boolean.parseBoolean(string)); + } else { + jsonGenerator.writeString(string); + } + } + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/random/ConstantZeroRandomNumberGenerator.java b/src/main/java/com/hubspot/jinjava/random/ConstantZeroRandomNumberGenerator.java new file mode 100644 index 000000000..1f67c6b8b --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/random/ConstantZeroRandomNumberGenerator.java @@ -0,0 +1,125 @@ +package com.hubspot.jinjava.random; + +import java.util.Random; +import java.util.stream.DoubleStream; +import java.util.stream.IntStream; +import java.util.stream.LongStream; + +/** + * A random number generator that always returns 0. Useful for testing code when you want the output to be constant. + */ +public class ConstantZeroRandomNumberGenerator extends Random { + + @Override + protected int next(int bits) { + return 0; + } + + @Override + public int nextInt() { + return 0; + } + + @Override + public int nextInt(int bound) { + return 0; + } + + @Override + public long nextLong() { + return 0; + } + + @Override + public boolean nextBoolean() { + return false; + } + + @Override + public float nextFloat() { + return 0f; + } + + @Override + public double nextDouble() { + return 0; + } + + @Override + public synchronized double nextGaussian() { + return 0; + } + + @Override + public void nextBytes(byte[] bytes) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints() { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(long streamSize, int randomNumberOrigin, int randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(int randomNumberOrigin, int randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs() { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs( + long streamSize, + long randomNumberOrigin, + long randomNumberBound + ) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs(long randomNumberOrigin, long randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles() { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles( + long streamSize, + double randomNumberOrigin, + double randomNumberBound + ) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles(double randomNumberOrigin, double randomNumberBound) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/random/DeferredRandomNumberGenerator.java b/src/main/java/com/hubspot/jinjava/random/DeferredRandomNumberGenerator.java new file mode 100644 index 000000000..e0ebedda7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/random/DeferredRandomNumberGenerator.java @@ -0,0 +1,128 @@ +package com.hubspot.jinjava.random; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import java.util.Random; +import java.util.stream.DoubleStream; +import java.util.stream.IntStream; +import java.util.stream.LongStream; + +/** + * A random number generator that throws {@link com.hubspot.jinjava.interpret.DeferredValueException} for all supported methods. + */ +public class DeferredRandomNumberGenerator extends Random { + + private static final String EXCEPTION_MESSAGE = "Generating random number"; + + @Override + protected int next(int bits) { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public int nextInt() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public int nextInt(int bound) { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public long nextLong() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public boolean nextBoolean() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public float nextFloat() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public double nextDouble() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public synchronized double nextGaussian() { + throw new DeferredValueException(EXCEPTION_MESSAGE); + } + + @Override + public void nextBytes(byte[] bytes) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints() { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(long streamSize, int randomNumberOrigin, int randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public IntStream ints(int randomNumberOrigin, int randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs() { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs( + long streamSize, + long randomNumberOrigin, + long randomNumberBound + ) { + throw new UnsupportedOperationException(); + } + + @Override + public LongStream longs(long randomNumberOrigin, long randomNumberBound) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles(long streamSize) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles() { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles( + long streamSize, + double randomNumberOrigin, + double randomNumberBound + ) { + throw new UnsupportedOperationException(); + } + + @Override + public DoubleStream doubles(double randomNumberOrigin, double randomNumberBound) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/random/RandomNumberGeneratorStrategy.java b/src/main/java/com/hubspot/jinjava/random/RandomNumberGeneratorStrategy.java new file mode 100644 index 000000000..bcb0ec9fc --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/random/RandomNumberGeneratorStrategy.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.random; + +public enum RandomNumberGeneratorStrategy { + THREAD_LOCAL, + CONSTANT_ZERO, + DEFERRED, +} diff --git a/src/main/java/com/hubspot/jinjava/tree/ExpressionNode.java b/src/main/java/com/hubspot/jinjava/tree/ExpressionNode.java index d2f7a67cc..ecaaef954 100644 --- a/src/main/java/com/hubspot/jinjava/tree/ExpressionNode.java +++ b/src/main/java/com/hubspot/jinjava/tree/ExpressionNode.java @@ -1,58 +1,58 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree; -import org.apache.commons.lang3.StringUtils; - +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.filter.EscapeFilter; -import com.hubspot.jinjava.lib.tag.AutoEscapeTag; +import com.hubspot.jinjava.lib.expression.DefaultExpressionStrategy; +import com.hubspot.jinjava.lib.expression.ExpressionStrategy; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; import com.hubspot.jinjava.tree.parse.ExpressionToken; -import com.hubspot.jinjava.util.Logging; -import com.hubspot.jinjava.util.ObjectValue; public class ExpressionNode extends Node { - private static final long serialVersionUID = 341642231109911346L; + private static final long serialVersionUID = -6063173739682221042L; + + private final ExpressionStrategy expressionStrategy; private final ExpressionToken master; public ExpressionNode(ExpressionToken token) { - super(token, token.getLineNumber()); + super(token, token.getLineNumber(), token.getStartPosition()); + this.expressionStrategy = new DefaultExpressionStrategy(); master = token; } - @Override - public String render(JinjavaInterpreter interpreter) { - Object var = interpreter.resolveELExpression(master.getExpr(), getLineNumber()); - - String result = ObjectValue.printable(var); - - if (!StringUtils.equals(result, master.getImage()) && StringUtils.contains(result, "{{")) { - try { - result = interpreter.renderString(result); - } catch (Exception e) { - Logging.ENGINE_LOG.warn("Error rendering variable node result", e); - } - } + public ExpressionNode(ExpressionStrategy expressionStrategy, ExpressionToken token) { + super(token, token.getLineNumber(), token.getStartPosition()); + this.expressionStrategy = expressionStrategy; + master = token; + } - if (interpreter.getContext().get(AutoEscapeTag.AUTOESCAPE_CONTEXT_VAR, Boolean.FALSE).equals(Boolean.TRUE)) { - result = EscapeFilter.escapeHtmlEntities(result); + @Override + public OutputNode render(JinjavaInterpreter interpreter) { + preProcess(interpreter); + try { + return expressionStrategy.interpretOutput(master, interpreter); + } catch (DeferredValueException e) { + interpreter.getContext().handleDeferredNode(this); + return new RenderedOutputNode(master.getImage()); + } finally { + postProcess(interpreter); } - - return result; } @Override @@ -64,5 +64,4 @@ public String toString() { public String getName() { return getClass().getSimpleName(); } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/Node.java b/src/main/java/com/hubspot/jinjava/tree/Node.java index 0a8132afe..56a8817f4 100644 --- a/src/main/java/com/hubspot/jinjava/tree/Node.java +++ b/src/main/java/com/hubspot/jinjava/tree/Node.java @@ -1,47 +1,44 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree; +import com.hubspot.jinjava.el.JinjavaProcessors; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.parse.Token; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; import java.io.Serializable; import java.util.LinkedList; - import org.apache.commons.lang3.StringUtils; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.tree.parse.Token; - public abstract class Node implements Serializable { - private static final long serialVersionUID = 7323842986596895498L; + private static final long serialVersionUID = -6194634312533310816L; - private int level; - private int lineNumber; + private final Token master; + private final int lineNumber; + private final int startPosition; private Node parent = null; - private LinkedList children = new LinkedList(); - - private Token master; + private LinkedList children = new LinkedList<>(); - public Node(Token master, int lineNumber) { + public Node(Token master, int lineNumber, int startPosition) { this.master = master; this.lineNumber = lineNumber; - } - - public int getLevel() { - return level; + this.startPosition = startPosition; } public Node getParent() { @@ -60,6 +57,10 @@ public int getLineNumber() { return lineNumber; } + public int getStartPosition() { + return startPosition; + } + public LinkedList getChildren() { return children; } @@ -68,25 +69,15 @@ public void setChildren(LinkedList children) { this.children = children; } - /** - * trusty call by TreeParser - * - * @param node - */ - void add(Node node) { - node.level = level + 1; - node.parent = this; - children.add(node); + public String reconstructImage() { + return master.getImage(); } - void computeLevel(int baseLevel) { - level = baseLevel; - for (Node child : children) { - child.computeLevel(level + 1); - } + public TokenScannerSymbols getSymbols() { + return master.getSymbols(); } - public abstract String render(JinjavaInterpreter interpreter); + public abstract OutputNode render(JinjavaInterpreter interpreter); public abstract String getName(); @@ -103,10 +94,23 @@ public String toTreeString(int level) { } if (getChildren().size() > 0) { - t.append(prefix).append("end :: " + toString()).append('\n'); + t.append(prefix).append("end :: ").append(this).append('\n'); } return t.toString(); } + public void preProcess(JinjavaInterpreter interpreter) { + JinjavaProcessors processors = interpreter.getConfig().getProcessors(); + if (processors != null && processors.getNodePreProcessor() != null) { + processors.getNodePreProcessor().accept(this, interpreter); + } + } + + public void postProcess(JinjavaInterpreter interpreter) { + JinjavaProcessors processors = interpreter.getConfig().getProcessors(); + if (processors != null && processors.getNodePostProcessor() != null) { + processors.getNodePostProcessor().accept(this, interpreter); + } + } } diff --git a/src/main/java/com/hubspot/jinjava/tree/RootNode.java b/src/main/java/com/hubspot/jinjava/tree/RootNode.java index 4d8c80bd1..7cf34432a 100644 --- a/src/main/java/com/hubspot/jinjava/tree/RootNode.java +++ b/src/main/java/com/hubspot/jinjava/tree/RootNode.java @@ -1,37 +1,46 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; public class RootNode extends Node { - private static final long serialVersionUID = 97675838726004658L; - RootNode() { - super(null, 0); + private static final long serialVersionUID = 5904181260202954424L; + private final TokenScannerSymbols symbols; + + RootNode(TokenScannerSymbols symbols) { + super(null, 0, 0); + this.symbols = symbols; } @Override - public String render(JinjavaInterpreter interpreter) { + public OutputNode render(JinjavaInterpreter interpreter) { throw new UnsupportedOperationException("Please render RootNode by interpreter"); } + @Override + public TokenScannerSymbols getSymbols() { + return symbols; + } + @Override public String getName() { return getClass().getSimpleName(); } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/TagNode.java b/src/main/java/com/hubspot/jinjava/tree/TagNode.java index 7d797e42d..495064a0b 100644 --- a/src/main/java/com/hubspot/jinjava/tree/TagNode.java +++ b/src/main/java/com/hubspot/jinjava/tree/TagNode.java @@ -1,56 +1,80 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.lib.tag.FlexibleTag; import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; public class TagNode extends Node { - private static final long serialVersionUID = 2405693063353887509L; + + private static final long serialVersionUID = -6971280448795354252L; private final Tag tag; private final TagToken master; private final String endName; - public TagNode(Tag tag, TagToken token) { - super(token, token.getLineNumber()); - + public TagNode(Tag tag, TagToken token, TokenScannerSymbols symbols) { + super(token, token.getLineNumber(), token.getStartPosition()); this.master = token; this.tag = tag; this.endName = tag.getEndTagName(); } - private TagNode(TagNode n) { - super(n.master, n.getLineNumber()); - - tag = n.tag; - master = n.master; - endName = n.endName; - } - @Override - public String render(JinjavaInterpreter interpreter) { + public OutputNode render(JinjavaInterpreter interpreter) { + preProcess(interpreter); try { - return tag.interpret(this, interpreter); - } catch (InterpretException e) { + if ( + interpreter.getContext().isValidationMode() && !tag.isRenderedInValidationMode() + ) { + return new RenderedOutputNode(""); + } + if (interpreter.getConfig().getExecutionMode().useEagerParser()) { + interpreter.getContext().checkNumberOfDeferredTokens(); + } + return tag.interpretOutput(this, interpreter); + } catch (DeferredValueException e) { + interpreter.getContext().handleDeferredNode(this); + return new RenderedOutputNode(reconstructImage()); + } catch ( + InterpretException + | InvalidInputException + | InvalidArgumentException + | OutputTooBigException e + ) { throw e; } catch (Exception e) { - throw new InterpretException("Error rendering tag", e, master.getLineNumber()); + throw new InterpretException( + "Error rendering tag", + e, + master.getLineNumber(), + master.getStartPosition() + ); + } finally { + postProcess(interpreter); } } @@ -72,4 +96,42 @@ public String getHelpers() { return master.getHelpers(); } + public Tag getTag() { + return tag; + } + + public String reconstructImage() { + StringBuilder builder = new StringBuilder().append(master.getImage()); + for (Node n : getChildren()) { + builder.append(n.reconstructImage()); + } + + if ( + getEndName() != null && + (!(tag instanceof FlexibleTag) || ((FlexibleTag) tag).hasEndTag(master)) + ) { + builder.append(reconstructEnd()); + } + + return builder.toString(); + } + + public String reconstructEnd() { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(getSymbols().getExpressionStartWithTag()); + if ( + getChildren() != null && + !getChildren().isEmpty() && + getChildren().getLast().getMaster().isRightTrim() + ) { + stringBuilder.append(getSymbols().getTrimChar()); + } + stringBuilder.append(" ").append(getEndName()).append(" "); + if (getMaster().isRightTrimAfterEnd()) { + stringBuilder.append(getSymbols().getTrimChar()); + } + + stringBuilder.append(getSymbols().getExpressionEndWithTag()); + return stringBuilder.toString(); + } } diff --git a/src/main/java/com/hubspot/jinjava/tree/TextNode.java b/src/main/java/com/hubspot/jinjava/tree/TextNode.java index 4bc0e3f35..64f572e2a 100644 --- a/src/main/java/com/hubspot/jinjava/tree/TextNode.java +++ b/src/main/java/com/hubspot/jinjava/tree/TextNode.java @@ -1,36 +1,46 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.tree.output.OutputNode; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; import com.hubspot.jinjava.tree.parse.TextToken; public class TextNode extends Node { - private static final long serialVersionUID = 8488738480534354216L; + + private static final long serialVersionUID = 127827773323298439L; private final TextToken master; public TextNode(TextToken token) { - super(token, token.getLineNumber()); + super(token, token.getLineNumber(), token.getStartPosition()); master = token; } @Override - public String render(JinjavaInterpreter interpreter) { - return master.output(); + public OutputNode render(JinjavaInterpreter interpreter) { + preProcess(interpreter); + try { + return new RenderedOutputNode( + interpreter.getContext().isValidationMode() ? "" : master.output() + ); + } finally { + postProcess(interpreter); + } } @Override @@ -42,5 +52,4 @@ public String toString() { public String getName() { return getClass().getSimpleName(); } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/TreeParser.java b/src/main/java/com/hubspot/jinjava/tree/TreeParser.java index bec51dc0e..fedadbaf9 100644 --- a/src/main/java/com/hubspot/jinjava/tree/TreeParser.java +++ b/src/main/java/com/hubspot/jinjava/tree/TreeParser.java @@ -15,109 +15,265 @@ **********************************************************************/ package com.hubspot.jinjava.tree; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_START; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_FIXED; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NOTE; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_TAG; - -import org.apache.commons.lang3.StringUtils; - import com.google.common.collect.Iterators; import com.google.common.collect.PeekingIterator; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.DisabledException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.interpret.MissingEndTagException; import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.interpret.UnexpectedTokenException; import com.hubspot.jinjava.interpret.UnknownTagException; import com.hubspot.jinjava.lib.tag.EndTag; +import com.hubspot.jinjava.lib.tag.FlexibleTag; import com.hubspot.jinjava.lib.tag.Tag; import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.tree.parse.StringTokenScanner; import com.hubspot.jinjava.tree.parse.TagToken; import com.hubspot.jinjava.tree.parse.TextToken; import com.hubspot.jinjava.tree.parse.Token; import com.hubspot.jinjava.tree.parse.TokenScanner; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.UnclosedToken; +import com.hubspot.jinjava.tree.parse.WhitespaceControlParser; +import java.util.Iterator; +import org.apache.commons.lang3.StringUtils; public class TreeParser { private final PeekingIterator scanner; private final JinjavaInterpreter interpreter; + private final TokenScannerSymbols symbols; + private final WhitespaceControlParser whitespaceControlParser; private Node parent; public TreeParser(JinjavaInterpreter interpreter, String input) { - this.scanner = Iterators.peekingIterator(new TokenScanner(input, interpreter.getConfig())); + this.scanner = + Iterators.peekingIterator(createScanner(input, interpreter.getConfig())); this.interpreter = interpreter; + this.symbols = interpreter.getConfig().getTokenScannerSymbols(); + this.whitespaceControlParser = + interpreter.getConfig().getLegacyOverrides().isParseWhitespaceControlStrictly() + ? WhitespaceControlParser.STRICT + : WhitespaceControlParser.LENIENT; } public Node buildTree() { - Node root = new RootNode(); + Node root = new RootNode(symbols); parent = root; while (scanner.hasNext()) { Node node = nextNode(); + if (node != null) { - parent.getChildren().add(node); + if ( + node instanceof TextNode && + getLastSibling() instanceof TextNode && + !interpreter.getConfig().getLegacyOverrides().isAllowAdjacentTextNodes() + ) { + // merge adjacent text nodes so whitespace control properly applies + ((TextToken) getLastSibling().getMaster()).mergeImageAndContent( + (TextToken) node.getMaster() + ); + } else { + parent.getChildren().add(node); + } } } - if (parent != root) { - interpreter.addError(TemplateError.fromException( - new MissingEndTagException(((TagNode) parent).getEndName(), parent.getMaster().getImage(), parent.getLineNumber()))); - } + do { + if (parent != root) { + interpreter.addError( + TemplateError.fromException( + new MissingEndTagException( + ((TagNode) parent).getEndName(), + parent.getMaster().getImage(), + parent.getLineNumber(), + parent.getStartPosition() + ) + ) + ); + parent = parent.getParent(); + } + } while (parent.getParent() != null); return root; } + private static Iterator createScanner(String input, JinjavaConfig config) { + if (config.getTokenScannerSymbols().isStringBased()) { + return new StringTokenScanner(input, config); + } + return new TokenScanner(input, config); + } + /** * @return null if EOF or error */ + private Node nextNode() { Token token = scanner.next(); + if (token.isLeftTrim() && isTrimmingEnabledForToken(token, interpreter.getConfig())) { + final Node lastSibling = getLastSibling(); + if (lastSibling instanceof TextNode) { + lastSibling.getMaster().setRightTrim(true); + } + } - switch (token.getType()) { - case TOKEN_FIXED: + if (token.getType() == symbols.getFixed()) { + if (token instanceof UnclosedToken) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "Unclosed token", + "token", + token.getLineNumber(), + token.getStartPosition(), + null + ) + ); + } return text((TextToken) token); - - case TOKEN_EXPR_START: + } else if (token.getType() == symbols.getExprStart()) { return expression((ExpressionToken) token); - - case TOKEN_TAG: + } else if (token.getType() == symbols.getTag()) { return tag((TagToken) token); - - case TOKEN_NOTE: - break; - - default: - interpreter.addError(TemplateError.fromException(new UnexpectedTokenException(token.getImage(), token.getLineNumber()))); + } else if (token.getType() == symbols.getNote()) { + String commentClosed = symbols.getClosingComment(); + if (!token.getImage().endsWith(commentClosed)) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "Unclosed comment", + "comment", + token.getLineNumber(), + token.getStartPosition(), + null + ) + ); + } + } else { + interpreter.addError( + TemplateError.fromException( + new UnexpectedTokenException( + token.getImage(), + token.getLineNumber(), + token.getStartPosition() + ) + ) + ); } - return null; } + private Node getLastSibling() { + if (parent == null || parent.getChildren().isEmpty()) { + return null; + } + return parent.getChildren().getLast(); + } + private Node text(TextToken textToken) { if (interpreter.getConfig().isLstripBlocks()) { - if (scanner.hasNext() && scanner.peek().getType() == TOKEN_TAG) { - textToken = new TextToken(StringUtils.stripEnd(textToken.getImage(), "\t "), textToken.getLineNumber()); + if (scanner.hasNext()) { + final int nextTokenType = scanner.peek().getType(); + if (nextTokenType == symbols.getTag() || nextTokenType == symbols.getNote()) { + textToken = + new TextToken( + StringUtils.stripEnd(textToken.getImage(), "\t "), + textToken.getLineNumber(), + textToken.getStartPosition(), + symbols, + whitespaceControlParser + ); + } } } + final Node lastSibling = getLastSibling(); + + // if last sibling was a tag and has rightTrimAfterEnd, strip whitespace + if ( + lastSibling != null && + isRightTrim(lastSibling) && + isTrimmingEnabledForToken(lastSibling.getMaster(), interpreter.getConfig()) + ) { + textToken.setLeftTrim(true); + } + + // for first TextNode child of TagNode where rightTrim is enabled, mark it for left trim + if ( + parent instanceof TagNode && lastSibling == null && parent.getMaster().isRightTrim() + ) { + textToken.setLeftTrim(true); + } + TextNode n = new TextNode(textToken); n.setParent(parent); return n; } + private boolean isRightTrim(Node lastSibling) { + if (lastSibling instanceof TagNode) { + return ( + ((TagNode) lastSibling).getEndName() == null || + (((TagNode) lastSibling).getTag() instanceof FlexibleTag && + !((FlexibleTag) ((TagNode) lastSibling).getTag()).hasEndTag( + (TagToken) lastSibling.getMaster() + )) + ) + ? lastSibling.getMaster().isRightTrim() + : lastSibling.getMaster().isRightTrimAfterEnd(); + } + return lastSibling.getMaster().isRightTrim(); + } + private Node expression(ExpressionToken expressionToken) { - ExpressionNode n = new ExpressionNode(expressionToken); + ExpressionNode n = createExpressionNode(expressionToken); n.setParent(parent); return n; } + private ExpressionNode createExpressionNode(ExpressionToken expressionToken) { + return new ExpressionNode( + interpreter.getContext().getExpressionStrategy(), + expressionToken + ); + } + private Node tag(TagToken tagToken) { - Tag tag = interpreter.getContext().getTag(tagToken.getTagName()); - if (tag == null) { - interpreter.addError(TemplateError.fromException(new UnknownTagException(tagToken))); + Tag tag; + try { + tag = interpreter.getContext().getTag(tagToken.getTagName()); + if (tag == null) { + interpreter.addError( + TemplateError.fromException(new UnknownTagException(tagToken)) + ); + return null; + } + } catch (DisabledException e) { + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.DISABLED, + ErrorItem.TAG, + e.getMessage(), + tagToken.getTagName(), + interpreter.getLineNumber(), + tagToken.getStartPosition(), + e + ) + ); return null; } @@ -126,10 +282,13 @@ private Node tag(TagToken tagToken) { return null; } - TagNode node = new TagNode(tag, tagToken); + TagNode node = new TagNode(tag, tagToken, symbols); node.setParent(parent); - if (node.getEndName() != null) { + if ( + node.getEndName() != null && + (!(tag instanceof FlexibleTag) || ((FlexibleTag) tag).hasEndTag(tagToken)) + ) { parent.getChildren().add(node); parent = node; return null; @@ -139,17 +298,51 @@ private Node tag(TagToken tagToken) { } private void endTag(Tag tag, TagToken tagToken) { + if (parent.getMaster() != null) { // root node + parent.getMaster().setRightTrimAfterEnd(tagToken.isRightTrim()); + } + + boolean hasMatchingStartTag = false; while (!(parent instanceof RootNode)) { TagNode parentTag = (TagNode) parent; parent = parent.getParent(); if (parentTag.getEndName().equals(tag.getEndTagName())) { + hasMatchingStartTag = true; break; } else { - interpreter.addError(TemplateError.fromException( - new TemplateSyntaxException(tagToken.getImage(), "Mismatched end tag, expected: " + parentTag.getEndName(), tagToken.getLineNumber()))); + interpreter.addError( + TemplateError.fromException( + new TemplateSyntaxException( + tagToken.getImage(), + "Mismatched end tag, expected: " + parentTag.getEndName(), + tagToken.getLineNumber(), + tagToken.getStartPosition() + ) + ) + ); } } + if (!hasMatchingStartTag) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "Missing start tag", + tag.getName(), + tagToken.getLineNumber(), + tagToken.getStartPosition(), + null + ) + ); + } } + private boolean isTrimmingEnabledForToken(Token token, JinjavaConfig jinjavaConfig) { + if (token instanceof TagToken || token instanceof TextToken) { + return true; + } + return jinjavaConfig.getLegacyOverrides().isUseTrimmingForNotesAndExpressions(); + } } diff --git a/src/main/java/com/hubspot/jinjava/tree/output/BlockInfo.java b/src/main/java/com/hubspot/jinjava/tree/output/BlockInfo.java new file mode 100644 index 000000000..8af0914e7 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/BlockInfo.java @@ -0,0 +1,45 @@ +package com.hubspot.jinjava.tree.output; + +import com.hubspot.jinjava.tree.Node; +import java.util.List; +import java.util.Optional; + +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public class BlockInfo { + + private final List nodes; + + private final Optional parentPath; + + private final int parentLineNo; + + private final int parentPosition; + + public BlockInfo( + List nodes, + Optional parentPath, + int parentLineNo, + int parentPosition + ) { + this.nodes = nodes; + this.parentPath = parentPath; + this.parentLineNo = parentLineNo; + this.parentPosition = parentPosition; + } + + public List getNodes() { + return nodes; + } + + public Optional getParentPath() { + return parentPath; + } + + public int getParentLineNo() { + return parentLineNo; + } + + public int getParentPosition() { + return parentPosition; + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/output/BlockPlaceholderOutputNode.java b/src/main/java/com/hubspot/jinjava/tree/output/BlockPlaceholderOutputNode.java new file mode 100644 index 000000000..c2801ab3a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/BlockPlaceholderOutputNode.java @@ -0,0 +1,47 @@ +package com.hubspot.jinjava.tree.output; + +import com.google.common.base.Charsets; +import java.nio.charset.Charset; + +public class BlockPlaceholderOutputNode implements OutputNode { + + private final String blockName; + private String output; + + public BlockPlaceholderOutputNode(String blockName) { + this.blockName = blockName; + } + + public String getBlockName() { + return blockName; + } + + public boolean isResolved() { + return output != null; + } + + public void resolve(String output) { + this.output = output; + } + + @Override + public String getValue() { + if (output == null) { + throw new IllegalStateException("Block placeholder not resolved: " + blockName); + } + + return output; + } + + @Override + public long getSize() { + return output == null + ? 0 + : output.getBytes(Charset.forName(Charsets.UTF_8.name())).length; + } + + @Override + public String toString() { + return getValue(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/output/DynamicRenderedOutputNode.java b/src/main/java/com/hubspot/jinjava/tree/output/DynamicRenderedOutputNode.java new file mode 100644 index 000000000..9967067b2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/DynamicRenderedOutputNode.java @@ -0,0 +1,33 @@ +package com.hubspot.jinjava.tree.output; + +import com.google.common.base.Charsets; +import java.nio.charset.Charset; + +/** + * An OutputNode that can be modified after already being added to the OutputList + */ +public class DynamicRenderedOutputNode implements OutputNode { + + protected String output = ""; + + public void setValue(String output) { + this.output = output; + } + + @Override + public String getValue() { + return output; + } + + @Override + public long getSize() { + return output == null + ? 0 + : output.getBytes(Charset.forName(Charsets.UTF_8.name())).length; + } + + @Override + public String toString() { + return getValue(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/output/OutputList.java b/src/main/java/com/hubspot/jinjava/tree/output/OutputList.java new file mode 100644 index 000000000..1d3b84710 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/OutputList.java @@ -0,0 +1,139 @@ +package com.hubspot.jinjava.tree.output; + +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.util.LinkedList; +import java.util.List; + +public class OutputList { + + public static final String PREVENT_ACCIDENTAL_EXPRESSIONS = + BuiltInFeatures.PREVENT_ACCIDENTAL_EXPRESSIONS; + private final List nodes = new LinkedList<>(); + private final List blocks = new LinkedList<>(); + private final long maxOutputSize; + private long currentSize; + + public OutputList(long maxOutputSize) { + this.maxOutputSize = maxOutputSize; + } + + public void addNode(OutputNode node) { + if (maxOutputSize > 0 && currentSize + node.getSize() > maxOutputSize) { + throw new OutputTooBigException(maxOutputSize, currentSize + node.getSize()); + } + + currentSize += node.getSize(); + nodes.add(node); + + if (node instanceof BlockPlaceholderOutputNode) { + BlockPlaceholderOutputNode blockNode = (BlockPlaceholderOutputNode) node; + + if (maxOutputSize > 0 && currentSize + blockNode.getSize() > maxOutputSize) { + throw new OutputTooBigException(maxOutputSize, currentSize + blockNode.getSize()); + } + + currentSize += blockNode.getSize(); + blocks.add(blockNode); + } + } + + public List getNodes() { + return nodes; + } + + public List getBlocks() { + return blocks; + } + + public String getValue() { + LengthLimitingStringBuilder val = new LengthLimitingStringBuilder(maxOutputSize); + + return JinjavaInterpreter + .getCurrentMaybe() + .map(JinjavaInterpreter::getConfig) + .filter(config -> + config + .getFeatures() + .getActivationStrategy(BuiltInFeatures.PREVENT_ACCIDENTAL_EXPRESSIONS) + .isActive(null) + ) + .map(config -> + joinNodesWithoutAddingExpressions(val, config.getTokenScannerSymbols()) + ) + .orElseGet(() -> joinNodes(val)); + } + + private String joinNodesWithoutAddingExpressions( + LengthLimitingStringBuilder val, + TokenScannerSymbols tokenScannerSymbols + ) { + String separator = getWhitespaceSeparator(tokenScannerSymbols); + String prev = null; + String cur; + for (OutputNode node : nodes) { + try { + cur = node.getValue(); + if ( + prev != null && + prev.length() > 0 && + prev.charAt(prev.length() - 1) == tokenScannerSymbols.getExprStartChar() + ) { + if ( + cur.length() > 0 && + TokenScannerSymbols.isNoteTagOrExprChar(tokenScannerSymbols, cur.charAt(0)) + ) { + val.append(separator); + } + } + prev = cur; + val.append(node.getValue()); + } catch (OutputTooBigException e) { + JinjavaInterpreter + .getCurrent() + .addError(TemplateError.fromOutputTooBigException(e)); + return val.toString(); + } + } + + return val.toString(); + } + + private static String getWhitespaceSeparator(TokenScannerSymbols tokenScannerSymbols) { + @SuppressWarnings("StringBufferReplaceableByString") + String separator = new StringBuilder() + .append('\n') + .append(tokenScannerSymbols.getPrefixChar()) + .append(tokenScannerSymbols.getNoteChar()) + .append(tokenScannerSymbols.getTrimChar()) + .append(' ') + .append(tokenScannerSymbols.getNoteChar()) + .append(tokenScannerSymbols.getExprEndChar()) + .toString(); + return separator; + } + + private String joinNodes(LengthLimitingStringBuilder val) { + for (OutputNode node : nodes) { + try { + val.append(node.getValue()); + } catch (OutputTooBigException e) { + JinjavaInterpreter + .getCurrent() + .addError(TemplateError.fromOutputTooBigException(e)); + return val.toString(); + } + } + + return val.toString(); + } + + @Override + public String toString() { + return getValue(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/output/OutputNode.java b/src/main/java/com/hubspot/jinjava/tree/output/OutputNode.java new file mode 100644 index 000000000..8d919a330 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/OutputNode.java @@ -0,0 +1,7 @@ +package com.hubspot.jinjava.tree.output; + +public interface OutputNode { + String getValue(); + + long getSize(); +} diff --git a/src/main/java/com/hubspot/jinjava/tree/output/RenderedOutputNode.java b/src/main/java/com/hubspot/jinjava/tree/output/RenderedOutputNode.java new file mode 100644 index 000000000..7c97ddf7d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/output/RenderedOutputNode.java @@ -0,0 +1,30 @@ +package com.hubspot.jinjava.tree.output; + +import com.google.common.base.Charsets; +import java.nio.charset.Charset; + +public class RenderedOutputNode implements OutputNode { + + private final String output; + + public RenderedOutputNode(String output) { + this.output = output; + } + + @Override + public String getValue() { + return output; + } + + @Override + public String toString() { + return getValue(); + } + + @Override + public long getSize() { + return output == null + ? 0 + : output.getBytes(Charset.forName(Charsets.UTF_8.name())).length; + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/DefaultTokenScannerSymbols.java b/src/main/java/com/hubspot/jinjava/tree/parse/DefaultTokenScannerSymbols.java new file mode 100644 index 000000000..e0ca20e20 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/DefaultTokenScannerSymbols.java @@ -0,0 +1,61 @@ +package com.hubspot.jinjava.tree.parse; + +public class DefaultTokenScannerSymbols extends TokenScannerSymbols { + + private static final long serialVersionUID = 3825893609777542598L; + + char TOKEN_PREFIX_CHAR = '{'; + char TOKEN_POSTFIX_CHAR = '}'; + char TOKEN_FIXED_CHAR = 0; + char TOKEN_NOTE_CHAR = '#'; + char TOKEN_TAG_CHAR = '%'; + char TOKEN_EXPR_START_CHAR = '{'; + char TOKEN_EXPR_END_CHAR = '}'; + char TOKEN_NEWLINE_CHAR = '\n'; + char TOKEN_TRIM_CHAR = '-'; + + @Override + public char getPrefixChar() { + return TOKEN_PREFIX_CHAR; + } + + @Override + public char getPostfixChar() { + return TOKEN_POSTFIX_CHAR; + } + + @Override + public char getFixedChar() { + return TOKEN_FIXED_CHAR; + } + + @Override + public char getNoteChar() { + return TOKEN_NOTE_CHAR; + } + + @Override + public char getTagChar() { + return TOKEN_TAG_CHAR; + } + + @Override + public char getExprStartChar() { + return TOKEN_EXPR_START_CHAR; + } + + @Override + public char getExprEndChar() { + return TOKEN_EXPR_END_CHAR; + } + + @Override + public char getNewlineChar() { + return TOKEN_NEWLINE_CHAR; + } + + @Override + public char getTrimChar() { + return TOKEN_TRIM_CHAR; + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/ExpressionToken.java b/src/main/java/com/hubspot/jinjava/tree/parse/ExpressionToken.java index efb1fd71f..1c0d679c0 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/ExpressionToken.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/ExpressionToken.java @@ -15,50 +15,58 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_START; - -import org.apache.commons.lang3.StringUtils; - import com.hubspot.jinjava.util.WhitespaceUtils; +import org.apache.commons.lang3.StringUtils; public class ExpressionToken extends Token { - private static final long serialVersionUID = 8307037212944170832L; + private static final long serialVersionUID = 6336768632140743908L; private String expr; - public ExpressionToken(String image, int lineNumber) { - super(image, lineNumber); + public ExpressionToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + this(image, lineNumber, startPosition, symbols, WhitespaceControlParser.LENIENT); + } + + public ExpressionToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { + super(image, lineNumber, startPosition, symbols, whitespaceControlParser); } @Override public String toString() { - StringBuilder s = new StringBuilder("{{ ").append(getExpr()).append("}}"); - return s.toString(); + return "{{ " + getExpr() + "}}"; } @Override public int getType() { - return TOKEN_EXPR_START; + return getSymbols().getExprStart(); } @Override protected void parse() { - this.expr = WhitespaceUtils.unwrap(image, "{{", "}}"); - - if (WhitespaceUtils.startsWith(expr, "-")) { - setLeftTrim(true); - this.expr = WhitespaceUtils.unwrap(expr, "-", ""); - } - if (WhitespaceUtils.endsWith(expr, "-")) { - setRightTrim(true); - this.expr = WhitespaceUtils.unwrap(expr, "", "-"); - } - + // Use the symbols-derived delimiter strings instead of the hardcoded "{{" / "}}" + // so that custom delimiters (e.g. "\VAR{" / "}") are stripped correctly. + this.expr = + WhitespaceUtils.unwrap( + image, + getSymbols().getExpressionStart(), + getSymbols().getExpressionEnd() + ); + this.expr = handleTrim(expr); this.expr = StringUtils.trimToEmpty(this.expr); } public String getExpr() { return expr; } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/LenientWhitespaceControlParser.java b/src/main/java/com/hubspot/jinjava/tree/parse/LenientWhitespaceControlParser.java new file mode 100644 index 000000000..25ad91e5e --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/LenientWhitespaceControlParser.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.tree.parse; + +import com.hubspot.jinjava.util.WhitespaceUtils; + +public class LenientWhitespaceControlParser implements WhitespaceControlParser { + + @Override + public boolean hasLeftTrim(String unwrapped) { + return WhitespaceUtils.startsWith(unwrapped, "-"); + } + + @Override + public String stripLeft(String unwrapped) { + return WhitespaceUtils.unwrap(unwrapped, "-", ""); + } + + @Override + public boolean hasRightTrim(String unwrapped) { + return WhitespaceUtils.endsWith(unwrapped, "-"); + } + + @Override + public String stripRight(String unwrapped) { + return WhitespaceUtils.unwrap(unwrapped, "", "-"); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/NoteToken.java b/src/main/java/com/hubspot/jinjava/tree/parse/NoteToken.java index be81809de..450f9ccbd 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/NoteToken.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/NoteToken.java @@ -15,19 +15,32 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NOTE; - public class NoteToken extends Token { - private static final long serialVersionUID = 6112027107603795408L; + private static final long serialVersionUID = -3859011447900311329L; + + public NoteToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + this(image, lineNumber, startPosition, symbols, WhitespaceControlParser.LENIENT); + } - public NoteToken(String image, int lineNumber) { - super(image, lineNumber); + public NoteToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { + super(image, lineNumber, startPosition, symbols, whitespaceControlParser); } @Override public int getType() { - return TOKEN_NOTE; + return getSymbols().getNote(); } /** @@ -35,6 +48,12 @@ public int getType() { */ @Override protected void parse() { + int startLen = getSymbols().getCommentStartLength(); + int endLen = getSymbols().getCommentEndLength(); + + if (image.length() > startLen + endLen) { + handleTrim(image.substring(startLen, image.length() - endLen)); + } content = ""; } @@ -42,5 +61,4 @@ protected void parse() { public String toString() { return "{# ----comment---- #}"; } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParser.java b/src/main/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParser.java new file mode 100644 index 000000000..f2ef3de1d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParser.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.tree.parse; + +public class StrictWhitespaceControlParser implements WhitespaceControlParser { + + @Override + public boolean hasLeftTrim(String unwrapped) { + return !unwrapped.isEmpty() && unwrapped.charAt(0) == '-'; + } + + @Override + public String stripLeft(String unwrapped) { + return unwrapped.isEmpty() ? unwrapped : unwrapped.substring(1); + } + + @Override + public boolean hasRightTrim(String unwrapped) { + return !unwrapped.isEmpty() && unwrapped.charAt(unwrapped.length() - 1) == '-'; + } + + @Override + public String stripRight(String unwrapped) { + return unwrapped.isEmpty() + ? unwrapped + : unwrapped.substring(0, unwrapped.length() - 1); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScanner.java b/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScanner.java new file mode 100644 index 000000000..0e5df631c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScanner.java @@ -0,0 +1,685 @@ +/* + Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree.parse; + +import static com.hubspot.jinjava.util.CharArrayUtils.charArrayRegionMatches; + +import com.google.common.collect.AbstractIterator; +import com.hubspot.jinjava.JinjavaConfig; + +/** + * String-matching token scanner for {@link TokenScannerSymbols} implementations + * where {@link TokenScannerSymbols#isStringBased()} returns {@code true} — most + * notably {@link StringTokenScannerSymbols}. + * + *

Unlike the character-based {@link TokenScanner}, this scanner matches + * multi-character delimiter strings directly (e.g. {@code \VAR{} / {@code }}, + * {@code \BLOCK{} / {@code }}) without relying on a shared prefix character. It also + * supports optional {@link TokenScannerSymbols#getLineStatementPrefix() line statement} + * and {@link TokenScannerSymbols#getLineCommentPrefix() line comment} prefixes, + * matching Python Jinja2 semantics. + * + *

{@link TreeParser} selects this scanner automatically when + * {@code symbols.isStringBased()} is {@code true}; callers never instantiate it + * directly. + */ +public class StringTokenScanner extends AbstractIterator { + + private final JinjavaConfig config; + + private final char[] is; + private final int length; + + private int currPost = 0; + private int tokenStart = 0; + private int tokenLength = 0; + private int tokenKind = -1; + private int lastStart = 0; + private int inComment = 0; + private int inRaw = 0; + private int inBlock = 0; + private char inQuote = 0; + private int currLine = 1; + private int lastNewlinePos = 0; + private final TokenScannerSymbols symbols; + private final WhitespaceControlParser whitespaceControlParser; + + private final char[] varStart; + private final char[] varEnd; + private final char[] blkStart; + private final char[] blkEnd; + private final char[] cmtStart; + private final char[] cmtEnd; + + // Optional line-oriented prefixes; null when not configured. + private final char[] lineStmtPrefix; + private final char[] lineCommentPrefix; + + // When true, backslash is treated as an escape character only inside quoted + // string literals, matching Jinja2 behaviour. When false (legacy default), + // the scanner consumes backslash + next char unconditionally. + private final boolean backslashInQuotesOnly; + + // Remembers where the current opening delimiter began so the emitted block/comment + // token image starts from the opener (not the content), letting parse() strip the + // correct number of delimiter characters from both ends. + private int blockOpenerStart = 0; + + public StringTokenScanner(String input, JinjavaConfig config) { + this.config = config; + + is = input.toCharArray(); + length = is.length; + + symbols = config.getTokenScannerSymbols(); + whitespaceControlParser = + config.getLegacyOverrides().isParseWhitespaceControlStrictly() + ? WhitespaceControlParser.STRICT + : WhitespaceControlParser.LENIENT; + + varStart = symbols.getExpressionStart().toCharArray(); + varEnd = symbols.getExpressionEnd().toCharArray(); + blkStart = symbols.getExpressionStartWithTag().toCharArray(); + blkEnd = symbols.getExpressionEndWithTag().toCharArray(); + cmtStart = symbols.getOpeningComment().toCharArray(); + cmtEnd = symbols.getClosingComment().toCharArray(); + + String lsp = symbols.getLineStatementPrefix(); + lineStmtPrefix = (lsp != null && !lsp.isEmpty()) ? lsp.toCharArray() : null; + + String lcp = symbols.getLineCommentPrefix(); + lineCommentPrefix = (lcp != null && !lcp.isEmpty()) ? lcp.toCharArray() : null; + + backslashInQuotesOnly = config.getLegacyOverrides().isHandleBackslashInQuotesOnly(); + } + + // ── Core scanning loop ──────────────────────────────────────────────────── + // + // tokenStart — start of the next text region to buffer. + // blockOpenerStart — position of the current opening delimiter; the emitted + // block/comment token image begins here. + // lastStart / tokenLength — the slice passed to Token.newToken(). + // + // Two-phase emission: + // 1. Opener detected → flush buffered plain text as TEXT, record + // blockOpenerStart, advance tokenStart/currPost past the opener into + // the block content, set inBlock/inComment. + // 2. Closer detected → emit is[blockOpenerStart .. closerEnd) as the + // appropriate token type; advance tokenStart = currPost = closerEnd. + + // Sentinel returned by scan helpers to mean "a delimiter was matched and + // scanner state was updated — loop again without advancing currPost". + // Any non-null return from a helper that is NOT this sentinel is a real token. + private static final Token DELIMITER_MATCHED = new TextToken( + "", + 0, + 0, + new DefaultTokenScannerSymbols() + ); + + private Token getNextToken() { + while (currPost < length) { + char c = is[currPost]; + + if (c == '\n') { + currLine++; + lastNewlinePos = currPost + 1; + } + + if (inComment > 0) { + Token t = scanInsideComment(); + if (t != null) { + return t; + } + continue; // scanInsideComment advanced currPost + } + + if (inBlock > 0) { + Token t = scanInsideBlock(c); + if (t == DELIMITER_MATCHED) { + continue; // closer not yet found, currPost already advanced + } + if (t != null) { + return t; + } + continue; + } + + if (inRaw == 0) { + Token t = scanPlainText(c); + if (t == DELIMITER_MATCHED) { + continue; // opener matched, state updated, no pending text + } + if (t != null) { + return t; // pending text flushed, or line-statement token + } + // null means nothing matched — fall through to advance + } else { + Token t = scanRawMode(); + if (t == DELIMITER_MATCHED) { + continue; + } + if (t != null) { + return t; + } + } + + currPost++; + } + + if (currPost > tokenStart) { + return getEndToken(); + } + return null; + } + + /** Scans one character while inside a comment block; advances {@code currPost}. */ + private Token scanInsideComment() { + if (regionMatches(currPost, cmtEnd)) { + lastStart = blockOpenerStart; + tokenLength = currPost + cmtEnd.length - blockOpenerStart; + tokenStart = currPost + cmtEnd.length; + currPost = tokenStart; + inComment = 0; + int kind = tokenKind; + tokenKind = symbols.getFixed(); + return emitToken(kind); + } + currPost++; + return null; + } + + /** + * Scans one character while inside a variable or tag block; advances + * {@code currPost}. Returns a real token when the closer is found, or + * {@link #DELIMITER_MATCHED} (meaning "keep looping") otherwise. + */ + private Token scanInsideBlock(char c) { + if (inQuote != 0) { + // Inside a quoted string: a backslash always escapes the next character. + if (c == '\\') { + currPost += (currPost + 1 < length) ? 2 : 1; + return DELIMITER_MATCHED; + } + if (c == inQuote) { + inQuote = 0; + } + currPost++; + return DELIMITER_MATCHED; + } + // Outside a quoted string: only consume the backslash if the legacy + // flag is enabled; otherwise leave it for the expression parser. + if (c == '\\' && !backslashInQuotesOnly) { + currPost += (currPost + 1 < length) ? 2 : 1; + return DELIMITER_MATCHED; + } + if (c == '\'' || c == '"') { + inQuote = c; + currPost++; + return DELIMITER_MATCHED; + } + // Check for the closing delimiter matching the current block type. + char[] closeDelim = closingDelimFor(tokenKind); + if (closeDelim != null && regionMatches(currPost, closeDelim)) { + lastStart = blockOpenerStart; + tokenLength = currPost + closeDelim.length - blockOpenerStart; + tokenStart = currPost + closeDelim.length; + currPost = tokenStart; + inBlock = 0; + int kind = tokenKind; + tokenKind = symbols.getFixed(); + return emitToken(kind); + } + currPost++; + return DELIMITER_MATCHED; + } + + /** + * Scans for openers while in normal (non-raw) plain-text mode. + * Returns a real token when one is ready to emit, {@link #DELIMITER_MATCHED} + * when an opener was matched with no pending text, or {@code null} when + * nothing matched (caller should advance {@code currPost}). + */ + private Token scanPlainText(char c) { + // ── Line statement prefix (e.g. "%% if foo") ────────────────────────── + if ( + lineStmtPrefix != null && + isStartOfLine(currPost) && + regionMatches(currPost, lineStmtPrefix) + ) { + return handleLineStatement(); + } + // ── Line comment prefix (e.g. "%# this is ignored") ─────────────────── + // Line comments match anywhere on a line, not just at the start. + if (lineCommentPrefix != null && regionMatches(currPost, lineCommentPrefix)) { + return handleLineComment(); + } + // ── Variable opener e.g. "{{" or "\VAR{" ────────────────────────────── + if (regionMatches(currPost, varStart)) { + return openBlock(varStart, symbols.getExprStart(), false); + } + // ── Block opener e.g. "{%" or "\BLOCK{" ─────────────────────────────── + if (regionMatches(currPost, blkStart)) { + return openBlock(blkStart, symbols.getTag(), false); + } + // ── Comment opener e.g. "{#" or "\#{" ───────────────────────────────── + if (regionMatches(currPost, cmtStart)) { + return openBlock(cmtStart, symbols.getNote(), true); + } + return null; // nothing matched + } + + /** + * Scans for the endraw block opener while in raw mode. + * Returns a real token, {@link #DELIMITER_MATCHED}, or {@code null}. + */ + private Token scanRawMode() { + if (regionMatches(currPost, blkStart)) { + int contentStart = currPost + blkStart.length; + int pos = contentStart; + while (pos < length && Character.isWhitespace(is[pos])) { + pos++; + } + if (charArrayRegionMatches(is, pos, "endraw")) { + Token pending = flushTextBefore(currPost); + blockOpenerStart = currPost; + tokenStart = contentStart; + currPost = tokenStart; + tokenKind = symbols.getTag(); + inBlock = 1; + if (pending != null) { + return pending; + } + return DELIMITER_MATCHED; + } + } + return null; + } + + /** + * Opens a variable or tag block (sets {@code inBlock}) or a comment block + * (sets {@code inComment}). Flushes any pending text first. + * Returns the pending text token if one exists, {@link #DELIMITER_MATCHED} otherwise. + */ + private Token openBlock(char[] opener, int kind, boolean isComment) { + Token pending = flushTextBefore(currPost); + blockOpenerStart = currPost; + tokenStart = currPost + opener.length; + currPost = tokenStart; + tokenKind = kind; + if (isComment) { + inComment = 1; + } else { + inBlock = 1; + } + return (pending != null) ? pending : DELIMITER_MATCHED; + } + + /** + * Handles a line statement prefix: consumes the line, builds a synthetic block + * tag token, and returns appropriately (stashing the tag if text was pending). + */ + private Token handleLineStatement() { + Token pending = flushTextBefore(lineIndentStart(currPost)); + + int contentStart = currPost + lineStmtPrefix.length; + while (contentStart < length && is[contentStart] == ' ') { + contentStart++; + } + int contentEnd = contentStart; + while (contentEnd < length && is[contentEnd] != '\n') { + contentEnd++; + } + // Do NOT trim inner here — TagToken.parse() calls handleTrim() which detects + // a leading '-' for left-trim whitespace control and a trailing '-' for + // right-trim. Trimming here would strip those control characters before + // TagToken ever sees them. + // Also do not insert a space before the content when it starts with the + // trim char '-', as that space would prevent handleTrim from detecting it. + String inner = String.valueOf(is, contentStart, contentEnd - contentStart); + String prefix = (inner.length() > 0 && inner.charAt(0) == symbols.getTrimChar()) + ? symbols.getExpressionStartWithTag() + : symbols.getExpressionStartWithTag() + " "; + String syntheticImage = prefix + inner + " " + symbols.getExpressionEndWithTag(); + + int next = contentEnd; + if (next < length && is[next] == '\n') { + next++; + currLine++; + lastNewlinePos = next; + } + + // When lstrip_blocks is active, Python Jinja2 also consumes any blank lines + // that follow a line statement (lines containing only horizontal whitespace). + // This prevents blank lines between consecutive line statements from + // appearing in the output. + if (config.isLstripBlocks()) { + while (next < length) { + // Scan forward past any horizontal whitespace on this line. + int lineEnd = next; + while ( + lineEnd < length && + is[lineEnd] != '\n' && + (is[lineEnd] == ' ' || is[lineEnd] == '\t') + ) { + lineEnd++; + } + // If we hit a newline (blank or whitespace-only line), consume it. + if (lineEnd < length && is[lineEnd] == '\n') { + next = lineEnd + 1; + currLine++; + lastNewlinePos = next; + } else { + // Hit real content or end of input — stop consuming. + break; + } + } + } + + tokenStart = next; + currPost = next; + + Token stmtToken = Token.newToken( + symbols.getTag(), + symbols, + whitespaceControlParser, + syntheticImage, + currLine, + 1 + ); + if (pending != null) { + pendingToken = stmtToken; + return pending; + } + return stmtToken; + } + + /** + * Handles a line comment prefix. + * + *

Line comments match anywhere on a line (not just at the start). + * For mid-line comments, everything from the prefix to end of line is + * stripped; the text before the prefix on the same line is kept. + * + *

Confirmed Python Jinja2 semantics: + *

    + *
  • Plain {@code %#}: comment content stripped, own trailing + * {@code \n} kept. Replaces the comment (and anything after it on + * the line) with a blank line / line ending.
  • + *
  • {@code %#-} at start of line: also strips preceding blank + * lines and the {@code \n} ending the last real-content line.
  • + *
  • {@code %#-} mid-line: behaves like plain {@code %#} — the + * {@code -} has nothing to left-trim when real content precedes it.
  • + *
+ */ + private Token handleLineComment() { + boolean startOfLine = isStartOfLine(currPost); + int afterPrefix = currPost + lineCommentPrefix.length; + boolean hasTrimModifier = + afterPrefix < length && is[afterPrefix] == symbols.getTrimChar(); + + int flushUpTo; + if (!startOfLine) { + // Mid-line comment: flush up to the %# prefix, stripping trailing + // horizontal whitespace before it (Python strips spaces/tabs before + // mid-line comments, e.g. "hello %# comment" → "hello"). + int p = currPost - 1; + while (p >= tokenStart && (is[p] == ' ' || is[p] == '\t')) { + p--; + } + flushUpTo = p + 1; + } else if (hasTrimModifier) { + // Start-of-line %#-: strip preceding blank lines and the real-content \n. + flushUpTo = lineIndentStartSkippingBlanks(currPost); + } else { + // Start-of-line %#: strip only the current line's indentation. + flushUpTo = lineIndentStart(currPost); + } + + Token pending = flushTextBefore(flushUpTo); + + // Advance past the comment content to the end of the line. + int end = afterPrefix; + while (end < length && is[end] != '\n') { + end++; + } + + // Both %# and %#- keep the trailing \n — it appears in the output. + tokenStart = end; + currPost = end; + + return (pending != null) ? pending : DELIMITER_MATCHED; + } + + /** + * Returns the position of the first character of the indentation on the line + * containing {@code pos} — i.e. the position just after the preceding newline + * (or 0 if at the start of input). Used to exclude leading horizontal whitespace + * from the text token flushed before a line prefix match. + */ + private int lineIndentStart(int pos) { + int p = pos - 1; + while (p >= 0 && (is[p] == ' ' || is[p] == '\t')) { + p--; + } + // p is now at the newline before the indentation, or at -1. + return p + 1; + } + + /** + * Returns the flush boundary for a {@code %#-} line comment. + * + *

Python Jinja2 semantics for {@code %#-}: strip back through any preceding + * blank lines AND the {@code \n} that ends the last real-content line, so that + * the comment's own kept {@code \n} becomes the sole separator. Stops at + * {@code tokenStart} so that {@code \n}s produced by preceding line statements + * or plain {@code %#} comments are not consumed. + * + *

Examples (| marks the flush boundary): + *

+   *   "A\n\n%#-"   →  flush "A|"      → output "A" + comment's \n
+   *   "%% set\n%#-" → flush nothing    → output comment's \n  (tokenStart guard)
+   * 
+ */ + private int lineIndentStartSkippingBlanks(int pos) { + int p = pos - 1; + while (p >= tokenStart) { + // Skip trailing horizontal whitespace on this line (going backwards). + while (p >= tokenStart && (is[p] == ' ' || is[p] == '\t')) { + p--; + } + if (p < tokenStart) { + break; + } + if (is[p] == '\n') { + // Blank line — consume this \n and keep scanning backwards. + p--; + } else { + // Real content at position p. The \n ending this line is at p+1. + // Return p+1 so flushTextBefore(p+1) flushes up to but NOT including + // that \n, stripping it from the output. + return p + 1; + } + } + // Reached tokenStart without finding real content — all blank lines were + // preceded by a line statement or plain comment. Preserve them. + return tokenStart; + } + + // ── One-slot stash for the synthetic tag after a line-statement ───────── + // When a line-statement prefix is found and there is pending text to flush + // first, we return the text token immediately and stash the synthetic tag + // here so computeNext() picks it up on the very next call. + private Token pendingToken = null; + + @Override + protected Token computeNext() { + // Drain any stashed token first. + if (pendingToken != null) { + Token t = pendingToken; + pendingToken = null; + return t; + } + + Token t = getNextToken(); + if (t == null) { + return endOfData(); + } + return t; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Returns true when {@code pos} is at the start of a line — i.e. it is either + * the very first character of the input, or the character immediately after a + * newline (accounting for any leading whitespace that lstripBlocks may allow). + */ + private boolean isStartOfLine(int pos) { + if (pos == 0) { + return true; + } + // Walk backwards past any horizontal whitespace (spaces/tabs). + int p = pos - 1; + while (p >= 0 && (is[p] == ' ' || is[p] == '\t')) { + p--; + } + // True if we hit the beginning of the input or a newline. + return p < 0 || is[p] == '\n'; + } + + /** + * If {@code is[tokenStart..upTo)} contains un-emitted plain text, captures it + * as a TEXT token and returns it. Returns {@code null} for zero-length regions. + * Does NOT update {@code tokenStart} — the caller sets it after returning. + */ + private Token flushTextBefore(int upTo) { + int textLen = upTo - tokenStart; + if (textLen <= 0) { + return null; + } + lastStart = tokenStart; + tokenLength = textLen; + return emitToken(symbols.getFixed()); + } + + /** Returns the closing delimiter for the currently open block kind. */ + private char[] closingDelimFor(int currentKind) { + if (currentKind == symbols.getExprStart()) { + return varEnd; + } + if (currentKind == symbols.getTag()) { + return blkEnd; + } + if (currentKind == symbols.getNote()) { + return cmtEnd; + } + return null; + } + + /** + * Constructs a token from {@code lastStart}/{@code tokenLength}, then applies + * trimBlocks and raw-mode post-processing identical to the char-based path. + */ + private Token emitToken(int kind) { + Token t = Token.newToken( + kind, + symbols, + whitespaceControlParser, + String.valueOf(is, lastStart, tokenLength), + currLine, + lastStart - lastNewlinePos + 1 + ); + + if ( + (t instanceof TagToken || t instanceof NoteToken) && + config.isTrimBlocks() && + currPost < length && + is[currPost] == '\n' + ) { + lastNewlinePos = currPost + 1; + ++currPost; + ++tokenStart; + } + + if (t instanceof TagToken) { + TagToken tt = (TagToken) t; + if ("raw".equals(tt.getTagName())) { + inRaw = 1; + return tt; + } else if ("endraw".equals(tt.getTagName())) { + inRaw = 0; + return tt; + } + } + + if (inRaw > 0 && t.getType() != symbols.getFixed()) { + return Token.newToken( + symbols.getFixed(), + symbols, + whitespaceControlParser, + t.image, + currLine, + lastStart - lastNewlinePos + 1 + ); + } + + return t; + } + + /** + * Emits whatever remains at end-of-input. + * Advances {@code tokenStart = currPost} so subsequent calls return null. + */ + private Token getEndToken() { + tokenLength = currPost - tokenStart; + lastStart = tokenStart; + tokenStart = currPost; + int type = symbols.getFixed(); + if (inComment > 0) { + type = symbols.getNote(); + } else if (inBlock > 0) { + return new UnclosedToken( + String.valueOf(is, lastStart, tokenLength), + currLine, + lastStart - lastNewlinePos + 1, + symbols, + whitespaceControlParser + ); + } + return Token.newToken( + type, + symbols, + whitespaceControlParser, + String.valueOf(is, lastStart, tokenLength), + currLine, + lastStart - lastNewlinePos + 1 + ); + } + + /** Returns true if {@code is[pos..]} starts with {@code pattern}. */ + private boolean regionMatches(int pos, char[] pattern) { + if (pos + pattern.length > length) { + return false; + } + for (int i = 0; i < pattern.length; i++) { + if (is[pos + i] != pattern[i]) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbols.java b/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbols.java new file mode 100644 index 000000000..242abd241 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbols.java @@ -0,0 +1,269 @@ +/********************************************************************** + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree.parse; + +/** + * A {@link TokenScannerSymbols} implementation that supports arbitrary multi-character + * delimiter strings, addressing + * issue #195. + * + *

Unlike {@link DefaultTokenScannerSymbols}, which is constrained to single-character + * prefixes and postfixes, this class allows any non-empty string for each of the six + * delimiter roles. The delimiters do not need to share a common prefix character. + * + *

{@link TokenScanner} detects this class via {@link #isStringBased()} and activates + * a string-matching scan path. {@link ExpressionToken}, {@link TagToken}, and + * {@link NoteToken} use the length accessors on {@link TokenScannerSymbols} (e.g. + * {@link #getExpressionStartLength()}) to strip delimiters correctly regardless of length. + * + *

The single-character abstract methods inherited from {@link TokenScannerSymbols} + * return private Unicode Private-Use-Area sentinel values. These are used only as + * token-kind discriminators inside {@link Token#newToken} and must never be used for + * scanning template text. + * + *

Example

+ *
{@code
+ * JinjavaConfig config = JinjavaConfig.newBuilder()
+ *     .withTokenScannerSymbols(StringTokenScannerSymbols.builder()
+ *         .withVariableStartString("\\VAR{")
+ *         .withVariableEndString("}")
+ *         .withBlockStartString("\\BLOCK{")
+ *         .withBlockEndString("}")
+ *         .withCommentStartString("\\#{")
+ *         .withCommentEndString("}")
+ *         .build())
+ *     .build();
+ * }
+ */ +public class StringTokenScannerSymbols extends TokenScannerSymbols { + + private static final long serialVersionUID = 1L; + + // ── Internal sentinel chars ──────────────────────────────────────────────── + // Unicode Private Use Area values — guaranteed never to appear in real template + // text, so Token.newToken()'s if-chain dispatches to the right Token subclass. + static final char SENTINEL_FIXED = '\uE000'; + static final char SENTINEL_NOTE = '\uE001'; + static final char SENTINEL_TAG = '\uE002'; + static final char SENTINEL_EXPR_START = '\uE003'; + static final char SENTINEL_EXPR_END = '\uE004'; + static final char SENTINEL_PREFIX = '\uE005'; // unused for scanning + static final char SENTINEL_POSTFIX = '\uE006'; // unused for scanning + static final char SENTINEL_NEWLINE = '\n'; // real newline for line tracking + static final char SENTINEL_TRIM = '-'; // real trim char + + // ── The configured string delimiters ────────────────────────────────────── + private final String variableStartString; + private final String variableEndString; + private final String blockStartString; + private final String blockEndString; + private final String commentStartString; + private final String commentEndString; + // Optional; null means disabled. + private final String lineStatementPrefix; + private final String lineCommentPrefix; + + private StringTokenScannerSymbols(Builder builder) { + this.variableStartString = builder.variableStartString; + this.variableEndString = builder.variableEndString; + this.blockStartString = builder.blockStartString; + this.blockEndString = builder.blockEndString; + this.commentStartString = builder.commentStartString; + this.commentEndString = builder.commentEndString; + this.lineStatementPrefix = builder.lineStatementPrefix; + this.lineCommentPrefix = builder.lineCommentPrefix; + } + + // ── Abstract char contract — returns sentinels only ─────────────────────── + + @Override + public char getPrefixChar() { + return SENTINEL_PREFIX; + } + + @Override + public char getPostfixChar() { + return SENTINEL_POSTFIX; + } + + @Override + public char getFixedChar() { + return SENTINEL_FIXED; + } + + @Override + public char getNoteChar() { + return SENTINEL_NOTE; + } + + @Override + public char getTagChar() { + return SENTINEL_TAG; + } + + @Override + public char getExprStartChar() { + return SENTINEL_EXPR_START; + } + + @Override + public char getExprEndChar() { + return SENTINEL_EXPR_END; + } + + @Override + public char getNewlineChar() { + return SENTINEL_NEWLINE; + } + + @Override + public char getTrimChar() { + return SENTINEL_TRIM; + } + + // ── String-level getters: MUST override the base-class lazy cache ────────── + // The base class builds these from the char methods above, which would produce + // garbage sentinel strings. We override them to return the real delimiters so + // that ExpressionToken, TagToken, and NoteToken strip content correctly. + + @Override + public String getExpressionStart() { + return variableStartString; + } + + @Override + public String getExpressionEnd() { + return variableEndString; + } + + @Override + public String getExpressionStartWithTag() { + return blockStartString; + } + + @Override + public String getExpressionEndWithTag() { + return blockEndString; + } + + @Override + public String getOpeningComment() { + return commentStartString; + } + + @Override + public String getClosingComment() { + return commentEndString; + } + + @Override + public String getLineStatementPrefix() { + return lineStatementPrefix; + } + + @Override + public String getLineCommentPrefix() { + return lineCommentPrefix; + } + + // ── isStringBased flag ──────────────────────────────────────────────────── + + @Override + public boolean isStringBased() { + return true; + } + + // ── Builder ──────────────────────────────────────────────────────────────── + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + // Defaults mirror the standard Jinja2 delimiters, so building with no + // overrides behaves identically to DefaultTokenScannerSymbols. + private String variableStartString = "{{"; + private String variableEndString = "}}"; + private String blockStartString = "{%"; + private String blockEndString = "%}"; + private String commentStartString = "{#"; + private String commentEndString = "#}"; + private String lineStatementPrefix = null; // disabled by default + private String lineCommentPrefix = null; // disabled by default + + public Builder withVariableStartString(String s) { + this.variableStartString = requireNonEmpty(s, "variableStartString"); + return this; + } + + public Builder withVariableEndString(String s) { + this.variableEndString = requireNonEmpty(s, "variableEndString"); + return this; + } + + public Builder withBlockStartString(String s) { + this.blockStartString = requireNonEmpty(s, "blockStartString"); + return this; + } + + public Builder withBlockEndString(String s) { + this.blockEndString = requireNonEmpty(s, "blockEndString"); + return this; + } + + public Builder withCommentStartString(String s) { + this.commentStartString = requireNonEmpty(s, "commentStartString"); + return this; + } + + public Builder withCommentEndString(String s) { + this.commentEndString = requireNonEmpty(s, "commentEndString"); + return this; + } + + /** + * Sets the line statement prefix (e.g. {@code "%%"}). A line beginning with + * this prefix is treated as a block tag, equivalent to wrapping its content + * in the configured block delimiters. Pass {@code null} to disable (default). + */ + public Builder withLineStatementPrefix(String s) { + this.lineStatementPrefix = s; + return this; + } + + /** + * Sets the line comment prefix (e.g. {@code "%#"}). A line beginning with + * this prefix is stripped entirely from the output. Pass {@code null} to + * disable (default). + */ + public Builder withLineCommentPrefix(String s) { + this.lineCommentPrefix = s; + return this; + } + + public StringTokenScannerSymbols build() { + return new StringTokenScannerSymbols(this); + } + + private static String requireNonEmpty(String value, String name) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be null or empty"); + } + return value; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/TagToken.java b/src/main/java/com/hubspot/jinjava/tree/parse/TagToken.java index 95de63b9b..0c500c145 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/TagToken.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/TagToken.java @@ -15,25 +15,38 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_TAG; - import com.hubspot.jinjava.interpret.TemplateSyntaxException; -import com.hubspot.jinjava.util.WhitespaceUtils; public class TagToken extends Token { - private static final long serialVersionUID = 2766011408032384360L; + private static final long serialVersionUID = -4927751270481832992L; private String tagName; + private String rawTagName; private String helpers; - public TagToken(String image, int lineNumber) { - super(image, lineNumber); + public TagToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + this(image, lineNumber, startPosition, symbols, WhitespaceControlParser.LENIENT); + } + + public TagToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { + super(image, lineNumber, startPosition, symbols, whitespaceControlParser); } @Override public int getType() { - return TOKEN_TAG; + return getSymbols().getTag(); } /** @@ -41,20 +54,20 @@ public int getType() { */ @Override protected void parse() { - if (image.length() < 4) { - throw new TemplateSyntaxException(image, "Malformed tag token", getLineNumber()); + int startLen = getSymbols().getTagStartLength(); + int endLen = getSymbols().getTagEndLength(); + + if (image.length() < startLen + endLen) { + throw new TemplateSyntaxException( + image, + "Malformed tag token", + getLineNumber(), + getStartPosition() + ); } - content = image.substring(2, image.length() - 2); - - if (WhitespaceUtils.startsWith(content, "-")) { - setLeftTrim(true); - content = WhitespaceUtils.unwrap(content, "-", ""); - } - if (WhitespaceUtils.endsWith(content, "-")) { - setRightTrim(true); - content = WhitespaceUtils.unwrap(content, "", "-"); - } + content = image.substring(startLen, image.length() - endLen); + content = handleTrim(content); int nameStart = -1, pos = 0, len = content.length(); @@ -62,19 +75,23 @@ protected void parse() { char c = content.charAt(pos); if (nameStart == -1 && Character.isJavaIdentifierStart(c)) { nameStart = pos; - } - else if (nameStart != -1 && !Character.isJavaIdentifierPart(c)) { + } else if (nameStart != -1 && !Character.isJavaIdentifierPart(c)) { break; } } if (pos < content.length()) { - tagName = content.substring(nameStart, pos).toLowerCase(); + rawTagName = content.substring(nameStart, pos); helpers = content.substring(pos); } else { - tagName = content.toLowerCase().trim(); + rawTagName = content.trim(); helpers = ""; } + tagName = rawTagName.toLowerCase(); + } + + public String getRawTagName() { + return rawTagName; } public String getTagName() { @@ -92,5 +109,4 @@ public String toString() { } return "{% " + tagName + " " + helpers + " %}"; } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/TextToken.java b/src/main/java/com/hubspot/jinjava/tree/parse/TextToken.java index 826ec0ac3..23288c5dc 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/TextToken.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/TextToken.java @@ -15,21 +15,41 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_FIXED; - import org.apache.commons.lang3.StringUtils; public class TextToken extends Token { - private static final long serialVersionUID = -5015884072204770458L; + private static final long serialVersionUID = -6168990984496468543L; + + public TextToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + this(image, lineNumber, startPosition, symbols, WhitespaceControlParser.LENIENT); + } + + public TextToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { + super(image, lineNumber, startPosition, symbols, whitespaceControlParser); + } - public TextToken(String image, int lineNumber) { - super(image, lineNumber); + public void mergeImageAndContent(TextToken otherToken) { + String thisOutput = output(); + String otherTokenOutput = otherToken.output(); + this.image = thisOutput + otherTokenOutput; + this.content = image; } @Override public int getType() { - return TOKEN_FIXED; + return getSymbols().getFixed(); } @Override @@ -46,6 +66,13 @@ public String trim() { } public String output() { + if (isLeftTrim() && isRightTrim()) { + return trim(); + } else if (isLeftTrim()) { + return StringUtils.stripStart(content, null); + } else if (isRightTrim()) { + return StringUtils.stripEnd(content, null); + } return content; } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/Token.java b/src/main/java/com/hubspot/jinjava/tree/parse/Token.java index cf889eaa1..d7071cc99 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/Token.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/Token.java @@ -15,31 +15,38 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_START; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_FIXED; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NOTE; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_TAG; - -import java.io.Serializable; - import com.hubspot.jinjava.interpret.UnexpectedTokenException; +import java.io.Serializable; public abstract class Token implements Serializable { - private static final long serialVersionUID = -7513379852268838992L; + private static final long serialVersionUID = 3359084948763661809L; - protected final String image; + protected String image; // useful for some token type protected String content; protected final int lineNumber; + protected final int startPosition; + private final TokenScannerSymbols symbols; + private final WhitespaceControlParser whitespaceControlParser; private boolean leftTrim; private boolean rightTrim; - - public Token(String image, int lineNumber) { + private boolean rightTrimAfterEnd; + + public Token( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { this.image = image; this.lineNumber = lineNumber; + this.startPosition = startPosition; + this.symbols = symbols != null ? symbols : new DefaultTokenScannerSymbols(); + this.whitespaceControlParser = whitespaceControlParser; parse(); } @@ -59,6 +66,10 @@ public boolean isRightTrim() { return rightTrim; } + public boolean isRightTrimAfterEnd() { + return rightTrimAfterEnd; + } + public void setLeftTrim(boolean leftTrim) { this.leftTrim = leftTrim; } @@ -67,6 +78,37 @@ public void setRightTrim(boolean rightTrim) { this.rightTrim = rightTrim; } + public void setRightTrimAfterEnd(boolean rightTrimAfterEnd) { + this.rightTrimAfterEnd = rightTrimAfterEnd; + } + + /** + * Handle any whitespace control characters, capturing whether leading or trailing + * whitespace should be stripped. + * @param unwrapped the content of the block stripped of its delimeters + * @return the content stripped of any whitespace control characters. + */ + protected final String handleTrim(String unwrapped) { + String result = unwrapped; + if (whitespaceControlParser.hasLeftTrim(result)) { + setLeftTrim(true); + result = whitespaceControlParser.stripLeft(result); + } + if (whitespaceControlParser.hasRightTrim(result)) { + setRightTrim(true); + result = whitespaceControlParser.stripRight(result); + } + return result; + } + + public int getStartPosition() { + return startPosition; + } + + public TokenScannerSymbols getSymbols() { + return symbols; + } + @Override public String toString() { return image; @@ -76,19 +118,52 @@ public String toString() { public abstract int getType(); - static Token newToken(int tokenKind, String image, int lineNumber) { - switch (tokenKind) { - case TOKEN_FIXED: - return new TextToken(image, lineNumber); - case TOKEN_NOTE: - return new NoteToken(image, lineNumber); - case TOKEN_EXPR_START: - return new ExpressionToken(image, lineNumber); - case TOKEN_TAG: - return new TagToken(image, lineNumber); - default: - throw new UnexpectedTokenException(String.valueOf((char) tokenKind), lineNumber); + static Token newToken( + int tokenKind, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser, + String image, + int lineNumber, + int startPosition + ) { + if (tokenKind == symbols.getFixed()) { + return new TextToken( + image, + lineNumber, + startPosition, + symbols, + whitespaceControlParser + ); + } else if (tokenKind == symbols.getNote()) { + return new NoteToken( + image, + lineNumber, + startPosition, + symbols, + whitespaceControlParser + ); + } else if (tokenKind == symbols.getExprStart()) { + return new ExpressionToken( + image, + lineNumber, + startPosition, + symbols, + whitespaceControlParser + ); + } else if (tokenKind == symbols.getTag()) { + return new TagToken( + image, + lineNumber, + startPosition, + symbols, + whitespaceControlParser + ); + } else { + throw new UnexpectedTokenException( + String.valueOf((char) tokenKind), + lineNumber, + startPosition + ); } } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/TokenScanner.java b/src/main/java/com/hubspot/jinjava/tree/parse/TokenScanner.java index 015b88fd3..de3b6e040 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/TokenScanner.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/TokenScanner.java @@ -1,54 +1,65 @@ -/********************************************************************** - * Copyright (c) 2014 HubSpot 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. - **********************************************************************/ +/* + Copyright (c) 2014 HubSpot 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.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_END; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_START; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_FIXED; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NEWLINE; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NOTE; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_POSTFIX; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_PREFIX; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_TAG; +import static com.hubspot.jinjava.util.CharArrayUtils.charArrayRegionMatches; import com.google.common.collect.AbstractIterator; import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.features.BuiltInFeatures; +/** + * Character-based token scanner for the standard single-character-prefix delimiter + * scheme (e.g. {@code {{}, {@code {%}, {@code {#}). + * + *

When {@link TokenScannerSymbols#isStringBased()} is {@code true}, + * {@link TreeParser} uses {@link StringTokenScanner} instead. + */ public class TokenScanner extends AbstractIterator { private final JinjavaConfig config; private final char[] is; + private final int length; + private int currPost = 0; private int tokenStart = 0; private int tokenLength = 0; private int tokenKind = -1; - private int length = 0; private int lastStart = 0; private int inComment = 0; private int inRaw = 0; private int inBlock = 0; private char inQuote = 0; private int currLine = 1; + private int lastNewlinePos = 0; + private final TokenScannerSymbols symbols; + private final WhitespaceControlParser whitespaceControlParser; + + // When true, backslash is treated as an escape character only inside quoted + // string literals, matching Jinja2 behaviour. When false (legacy default), + // the scanner consumes backslash + next char unconditionally. + private final boolean backslashInQuotesOnly; public TokenScanner(String input, JinjavaConfig config) { this.config = config; is = input.toCharArray(); - length = input.length(); + length = is.length; + currPost = 0; tokenStart = 0; tokenKind = -1; @@ -58,10 +69,18 @@ public TokenScanner(String input, JinjavaConfig config) { inBlock = 0; inQuote = 0; currLine = 1; + lastNewlinePos = 0; + + symbols = config.getTokenScannerSymbols(); + whitespaceControlParser = + config.getLegacyOverrides().isParseWhitespaceControlStrictly() + ? WhitespaceControlParser.STRICT + : WhitespaceControlParser.LENIENT; + backslashInQuotesOnly = config.getLegacyOverrides().isHandleBackslashInQuotesOnly(); } private Token getNextToken() { - char c = 0; + char c; while (currPost < length) { c = is[currPost++]; if (currPost == length) { @@ -69,85 +88,89 @@ private Token getNextToken() { } if (inBlock > 0) { - if (inQuote != 0) { - if (inQuote == c) { - inQuote = 0; - continue; - } else if (c == '\\') { + if (c == '\\' && !backslashInQuotesOnly) { + ++currPost; + continue; + } else if (inQuote != 0) { + if (c == '\\') { ++currPost; continue; - } else { - continue; } - } else if (inQuote == 0 && (c == '\'' || c == '"')) { + if (inQuote == c) { + inQuote = 0; + } + continue; + } else if (c == '\'' || c == '"') { inQuote = c; continue; } } - switch (c) { - case TOKEN_PREFIX: + // models switch case into if-else blocks + if (c == symbols.getPrefix()) { if (currPost < length) { c = is[currPost]; - switch (c) { - case TOKEN_NOTE: - if (inComment == 1 || inRaw == 1) { - continue; - } - inComment = 1; + boolean startTokenFound = true; + if ( + config + .getFeatures() + .isActive(BuiltInFeatures.WHITESPACE_REQUIRED_WITHIN_TOKENS) + ) { + boolean hasNextChar = (currPost + 1) < length; + boolean nextCharIsWhitespace = hasNextChar && (' ' == is[currPost + 1]); + startTokenFound = nextCharIsWhitespace; + } + if (startTokenFound) { + if (c == symbols.getNote()) { + if (inComment == 1 || inRaw == 1) { + continue; + } + inComment = 1; - tokenLength = currPost - tokenStart - 1; - if (tokenLength > 0) { - // start a new token - lastStart = tokenStart; - tokenStart = --currPost; - tokenKind = c; - inComment = 0; - return newToken(TOKEN_FIXED); - } else { - tokenKind = c; - } - break; - case TOKEN_TAG: - case TOKEN_EXPR_START: - if (inComment > 0) { - continue; - } - if (inRaw > 0 && (c == TOKEN_EXPR_START || !isEndRaw())) { - continue; - } - // match token two ends - if (!matchToken(c) && tokenKind > 0) { - continue; - } - if (inBlock++ > 0) { - continue; - } + tokenLength = currPost - tokenStart - 1; + if (tokenLength > 0) { + // start a new token + lastStart = tokenStart; + tokenStart = --currPost; + tokenKind = c; + inComment = 0; + return newToken(symbols.getFixed()); + } else { + tokenKind = c; + } + } else if (c == symbols.getTag() || c == symbols.getExprStart()) { + if (inComment > 0) { + continue; + } + if (inRaw > 0 && (c == symbols.getExprStart() || !isEndRaw())) { + continue; + } + // match token two ends + if (!matchToken(c) && tokenKind > 0) { + continue; + } + if (inBlock++ > 0) { + continue; + } - tokenLength = currPost - tokenStart - 1; - if (tokenLength > 0) { - // start a new token - lastStart = tokenStart; - tokenStart = --currPost; - tokenKind = c; - return newToken(TOKEN_FIXED); - } else { - tokenKind = c; + tokenLength = currPost - tokenStart - 1; + if (tokenLength > 0) { + // start a new token + lastStart = tokenStart; + tokenStart = --currPost; + tokenKind = c; + return newToken(symbols.getFixed()); + } else { + tokenKind = c; + } } - break; - default: - break; } - } - // reach the stream end - else { + } else { // reach the stream end return getEndToken(); } - break; + } else if (c == symbols.getTag() || c == symbols.getExprEnd()) { + // maybe current token is closing - // maybe current token is closing - case TOKEN_TAG: - case TOKEN_EXPR_END: if (inComment > 0) { continue; } @@ -156,7 +179,7 @@ private Token getNextToken() { } if (currPost < length) { c = is[currPost]; - if (c == TOKEN_POSTFIX) { + if (c == symbols.getPostfix()) { inBlock = 0; tokenLength = currPost - tokenStart + 1; @@ -165,21 +188,20 @@ private Token getNextToken() { lastStart = tokenStart; tokenStart = ++currPost; int kind = tokenKind; - tokenKind = TOKEN_FIXED; + tokenKind = symbols.getFixed(); return newToken(kind); } } } else { return getEndToken(); } - break; - case TOKEN_NOTE: + } else if (c == symbols.getNote()) { // case 3 if (!matchToken(c)) { continue; } if (currPost < length) { c = is[currPost]; - if (c == TOKEN_POSTFIX) { + if (c == symbols.getPostfix()) { inComment = 0; tokenLength = currPost - tokenStart + 1; @@ -187,26 +209,25 @@ private Token getNextToken() { // start a new token lastStart = tokenStart; tokenStart = ++currPost; - tokenKind = TOKEN_FIXED; - return newToken(TOKEN_NOTE); + tokenKind = symbols.getFixed(); + return newToken(symbols.getNote()); } } } else { return getEndToken(); } - break; - case TOKEN_NEWLINE: + } else if (c == symbols.getNewline()) { currLine++; + lastNewlinePos = currPost; if (inComment > 0 || inBlock > 0) { continue; } - tokenKind = TOKEN_FIXED; - break; - default: + tokenKind = symbols.getFixed(); + } else { if (tokenKind == -1) { - tokenKind = TOKEN_FIXED; + tokenKind = symbols.getFixed(); } } } @@ -225,27 +246,55 @@ private boolean isEndRaw() { return false; } - return "endraw".equals(String.valueOf(is, pos - 1, 6)); + return charArrayRegionMatches(is, pos - 1, "endraw"); } private Token getEndToken() { tokenLength = currPost - tokenStart; - int type = TOKEN_FIXED; + int type = symbols.getFixed(); if (inComment > 0) { - type = TOKEN_NOTE; + type = symbols.getNote(); + } else if (inBlock > 0) { + return new UnclosedToken( + String.valueOf(is, tokenStart, tokenLength), + currLine, + tokenStart - lastNewlinePos + 1, + symbols, + whitespaceControlParser + ); } - return Token.newToken(type, String.valueOf(is, tokenStart, tokenLength), currLine); + return Token.newToken( + type, + symbols, + whitespaceControlParser, + String.valueOf(is, tokenStart, tokenLength), + currLine, + tokenStart - lastNewlinePos + 1 + ); } private Token newToken(int kind) { - Token t = Token.newToken(kind, String.valueOf(is, lastStart, tokenLength), currLine); + Token t = Token.newToken( + kind, + symbols, + whitespaceControlParser, + String.valueOf(is, lastStart, tokenLength), + currLine, + lastStart - lastNewlinePos + 1 + ); - if (t instanceof TagToken) { - if (config.isTrimBlocks() && is[currPost] == '\n') { - ++currPost; - ++tokenStart; - } + if ( + (t instanceof TagToken || t instanceof NoteToken) && + config.isTrimBlocks() && + currPost < length && + is[currPost] == '\n' + ) { + lastNewlinePos = currPost; + ++currPost; + ++tokenStart; + } + if (t instanceof TagToken) { TagToken tt = (TagToken) t; if ("raw".equals(tt.getTagName())) { inRaw = 1; @@ -256,18 +305,25 @@ private Token newToken(int kind) { } } - if (inRaw > 0 && t.getType() != TOKEN_FIXED) { - return Token.newToken(TOKEN_FIXED, t.image, currLine); + if (inRaw > 0 && t.getType() != symbols.getFixed()) { + return Token.newToken( + symbols.getFixed(), + symbols, + whitespaceControlParser, + t.image, + currLine, + tokenStart + ); } return t; } private boolean matchToken(char kind) { - if (kind == TOKEN_EXPR_START) { - return tokenKind == TOKEN_EXPR_END; - } else if (kind == TOKEN_EXPR_END) { - return tokenKind == TOKEN_EXPR_START; + if (kind == symbols.getExprStart()) { + return tokenKind == symbols.getExprEnd(); + } else if (kind == symbols.getExprEnd()) { + return tokenKind == symbols.getExprStart(); } else { return kind == tokenKind; } @@ -283,5 +339,4 @@ protected Token computeNext() { return t; } - } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/TokenScannerSymbols.java b/src/main/java/com/hubspot/jinjava/tree/parse/TokenScannerSymbols.java index 481765bee..638220853 100644 --- a/src/main/java/com/hubspot/jinjava/tree/parse/TokenScannerSymbols.java +++ b/src/main/java/com/hubspot/jinjava/tree/parse/TokenScannerSymbols.java @@ -15,15 +15,204 @@ **********************************************************************/ package com.hubspot.jinjava.tree.parse; -public interface TokenScannerSymbols { +import java.io.Serializable; - int TOKEN_PREFIX = '{'; - int TOKEN_POSTFIX = '}'; - int TOKEN_FIXED = 0; - int TOKEN_NOTE = '#'; - int TOKEN_TAG = '%'; - int TOKEN_EXPR_START = '{'; - int TOKEN_EXPR_END = '}'; - int TOKEN_NEWLINE = '\n'; +public abstract class TokenScannerSymbols implements Serializable { + private static final long serialVersionUID = -4810220023023256534L; + + private String expressionStart = null; + private String expressionStartWithTag = null; + private String openingComment = null; + private String closingComment = null; + private String expressionEnd = null; + private String expressionEndWithTag = null; + + public abstract char getPrefixChar(); + + public abstract char getPostfixChar(); + + public abstract char getFixedChar(); + + public abstract char getNoteChar(); + + public abstract char getTagChar(); + + public abstract char getExprStartChar(); + + public abstract char getExprEndChar(); + + public abstract char getNewlineChar(); + + public abstract char getTrimChar(); + + public int getPrefix() { + return getPrefixChar(); + } + + public int getPostfix() { + return getPostfixChar(); + } + + public int getFixed() { + return getFixedChar(); + } + + public int getNote() { + return getNoteChar(); + } + + public int getTag() { + return getTagChar(); + } + + public int getExprStart() { + return getExprStartChar(); + } + + public int getExprEnd() { + return getExprEndChar(); + } + + public int getNewline() { + return getNewlineChar(); + } + + public int getTrim() { + return getTrimChar(); + } + + public String getExpressionStart() { + if (expressionStart == null) { + expressionStart = String.valueOf(getPrefixChar()) + getExprStartChar(); + } + return expressionStart; + } + + public String getExpressionEnd() { + if (expressionEnd == null) { + expressionEnd = String.valueOf(getExprEndChar()) + getPostfixChar(); + } + return expressionEnd; + } + + public String getExpressionStartWithTag() { + if (expressionStartWithTag == null) { + expressionStartWithTag = String.valueOf(getPrefixChar()) + getTagChar(); + } + return expressionStartWithTag; + } + + public String getExpressionEndWithTag() { + if (expressionEndWithTag == null) { + expressionEndWithTag = String.valueOf(getTagChar()) + getPostfixChar(); + } + return expressionEndWithTag; + } + + public String getOpeningComment() { + if (openingComment == null) { + openingComment = String.valueOf(getPrefixChar()) + getNoteChar(); + } + return openingComment; + } + + public String getClosingComment() { + if (closingComment == null) { + closingComment = String.valueOf(getNoteChar()) + getPostfixChar(); + } + return closingComment; + } + + public static boolean isNoteTagOrExprChar(TokenScannerSymbols symbols, char c) { + return ( + c == symbols.getNote() || c == symbols.getTag() || c == symbols.getExprStartChar() + ); + } + + // ── New API ──────────────────────────────────────────────────────────────── + + /** + * Returns {@code true} if this instance uses arbitrary string delimiters that + * require the string-matching scan path in {@link TokenScanner}. + * + *

The default returns {@code false}, so all existing subclasses are unaffected. + * {@link StringTokenScannerSymbols} overrides this to return {@code true}. + */ + public boolean isStringBased() { + return false; + } + + /** + * Length of the variable/expression opening delimiter (e.g. 2 for {@code "{{"}), + * used by {@link ExpressionToken#parse()} instead of the hardcoded constant 2. + */ + public int getExpressionStartLength() { + return getExpressionStart().length(); + } + + /** + * Length of the variable/expression closing delimiter (e.g. 2 for {@code "}}"}), + * used by {@link ExpressionToken#parse()} instead of the hardcoded constant 2. + */ + public int getExpressionEndLength() { + return getExpressionEnd().length(); + } + + /** + * Length of the block/tag opening delimiter (e.g. 2 for {@code "{%"}), + * used by {@link TagToken#parse()} instead of the hardcoded constant 2. + */ + public int getTagStartLength() { + return getExpressionStartWithTag().length(); + } + + /** + * Length of the block/tag closing delimiter (e.g. 2 for {@code "%}"}), + * used by {@link TagToken#parse()} instead of the hardcoded constant 2. + */ + public int getTagEndLength() { + return getExpressionEndWithTag().length(); + } + + /** + * Length of the comment opening delimiter (e.g. 2 for {@code "{#"}), + * used by {@link NoteToken#parse()} instead of the hardcoded constant 2. + */ + public int getCommentStartLength() { + return getOpeningComment().length(); + } + + /** + * Length of the comment closing delimiter (e.g. 2 for {@code "#}"}), + * used by {@link NoteToken#parse()} instead of the hardcoded constant 2. + */ + public int getCommentEndLength() { + return getClosingComment().length(); + } + + /** + * Optional line statement prefix (e.g. {@code "%%"}). When non-null, any line + * that begins with this prefix (after optional horizontal whitespace) is treated + * as a block tag statement, equivalent to wrapping its content in the block + * delimiters. Returns {@code null} by default (feature disabled). + * + *

Only used by {@link StringTokenScannerSymbols}; has no effect in the + * char-based path. + */ + public String getLineStatementPrefix() { + return null; + } + + /** + * Optional line comment prefix (e.g. {@code "%#"}). When non-null, any line + * that begins with this prefix (after optional horizontal whitespace) is stripped + * entirely from the output. Returns {@code null} by default (feature disabled). + * + *

Only used by {@link StringTokenScannerSymbols}; has no effect in the + * char-based path. + */ + public String getLineCommentPrefix() { + return null; + } } diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/UnclosedToken.java b/src/main/java/com/hubspot/jinjava/tree/parse/UnclosedToken.java new file mode 100644 index 000000000..76e5ca1b4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/UnclosedToken.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.tree.parse; + +public class UnclosedToken extends TextToken { + + public UnclosedToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols + ) { + this(image, lineNumber, startPosition, symbols, WhitespaceControlParser.LENIENT); + } + + public UnclosedToken( + String image, + int lineNumber, + int startPosition, + TokenScannerSymbols symbols, + WhitespaceControlParser whitespaceControlParser + ) { + super(image, lineNumber, startPosition, symbols, whitespaceControlParser); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/parse/WhitespaceControlParser.java b/src/main/java/com/hubspot/jinjava/tree/parse/WhitespaceControlParser.java new file mode 100644 index 000000000..2f80e0602 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/tree/parse/WhitespaceControlParser.java @@ -0,0 +1,12 @@ +package com.hubspot.jinjava.tree.parse; + +public interface WhitespaceControlParser { + WhitespaceControlParser LENIENT = new LenientWhitespaceControlParser(); + WhitespaceControlParser STRICT = new StrictWhitespaceControlParser(); + + boolean hasLeftTrim(String unwrapped); + String stripLeft(String unwrapped); + + boolean hasRightTrim(String unwrapped); + String stripRight(String unwrapped); +} diff --git a/src/main/java/com/hubspot/jinjava/util/CharArrayUtils.java b/src/main/java/com/hubspot/jinjava/util/CharArrayUtils.java new file mode 100644 index 000000000..e337166ad --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/CharArrayUtils.java @@ -0,0 +1,24 @@ +package com.hubspot.jinjava.util; + +public class CharArrayUtils { + + public static boolean charArrayRegionMatches( + char[] value, + int startPos, + CharSequence toMatch + ) { + int matchLen = toMatch.length(), endPos = startPos + matchLen; + + if (endPos > value.length) { + return false; + } + + for (int matchIndex = 0, i = startPos; i < endPos; i++, matchIndex++) { + if (value[i] != toMatch.charAt(matchIndex)) { + return false; + } + } + + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/DeferredValueUtils.java b/src/main/java/com/hubspot/jinjava/util/DeferredValueUtils.java new file mode 100644 index 000000000..a2f11f8f9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/DeferredValueUtils.java @@ -0,0 +1,238 @@ +package com.hubspot.jinjava.util; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.interpret.PartiallyDeferredValue; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.tree.ExpressionNode; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.TextNode; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.StringJoiner; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class DeferredValueUtils { + + private static final String TEMPLATE_TAG_REGEX = "(\\w+(?:\\.\\w+)*)"; + private static final Pattern TEMPLATE_TAG_PATTERN = Pattern.compile(TEMPLATE_TAG_REGEX); + + public static boolean isFullyDeferred(Object obj) { + return obj instanceof DeferredValue && !(obj instanceof PartiallyDeferredValue); + } + + public static HashMap getDeferredContextWithOriginalValues( + Map context + ) { + return getDeferredContextWithOriginalValues(context, ImmutableSet.of()); + } + + //The context needed for a second render + //Ignores deferred properties with no originalValue + //Optionally only keep keys in keysToKeep + public static HashMap getDeferredContextWithOriginalValues( + Map context, + Set keysToKeep + ) { + HashMap deferredContext = new HashMap<>(context.size()); + context.forEach((contextKey, contextItem) -> { + if (keysToKeep.size() > 0 && !keysToKeep.contains(contextKey)) { + return; + } + if (contextItem instanceof DeferredValue) { + if (((DeferredValue) contextItem).getOriginalValue() != null) { + deferredContext.put( + contextKey, + ((DeferredValue) contextItem).getOriginalValue() + ); + } + } + }); + return deferredContext; + } + + public static void deferVariables(String[] varTokens, Map context) { + for (String varToken : varTokens) { + String key = varToken.trim(); + Object originalValue = context.get(key); + if (originalValue != null) { + if (originalValue instanceof DeferredValue) { + context.put(key, originalValue); + } else { + context.put(key, DeferredValue.instance(originalValue)); + } + } else { + context.put(key, DeferredValue.instance()); + } + } + } + + public static Set findAndMarkDeferredProperties(Context context, Node newNode) { + String templateSource = rebuildTemplateForNodes(newNode); + Set deferredProps = getPropertiesUsedInDeferredNodes(context, templateSource); + Set setProps = getPropertiesSetInDeferredNodes(templateSource); + markDeferredProperties(context, Sets.union(deferredProps, setProps)); + return deferredProps; + } + + public static Set getPropertiesSetInDeferredNodes(String templateSource) { + return findSetProperties(templateSource); + } + + public static Set getDeferredTagsRecursively(Node deferredNode) { + return getDeferredTags(deferredNode, 0); + } + + public static Set getPropertiesUsedInDeferredNodes( + Context context, + String templateSource + ) { + return getPropertiesUsedInDeferredNodes(context, templateSource, true); + } + + public static Set getPropertiesUsedInDeferredNodes( + Context context, + String templateSource, + boolean onlyAlreadyInContext + ) { + Stream propertiesUsed = findUsedProperties(templateSource) + .stream() + .map(prop -> prop.split("\\.", 2)[0]); // split accesses on .prop + if (onlyAlreadyInContext) { + propertiesUsed = propertiesUsed.filter(context::containsKey); + } + return propertiesUsed.collect(Collectors.toSet()); + } + + private static void markDeferredProperties(Context context, Set props) { + props + .stream() + .filter(prop -> !(context.get(prop) instanceof DeferredValue)) + .filter(prop -> !MetaContextVariables.isMetaContextVariable(prop, context)) + .forEach(prop -> { + Object value = context.get(prop); + if (value != null) { + context.put(prop, DeferredValue.instance(value)); + } else { + //Handle set props + context.put(prop, DeferredValue.instance()); + } + }); + } + + private static Set getDeferredTags(Node node, int depth) { + // precaution - templates are parsed with this render depth so in theory the depth should never be exceeded + Set deferredTags = getDeferredTags(node).orElse(new HashSet<>()); + int maxRenderDepth = JinjavaInterpreter.getCurrent() == null + ? 3 + : JinjavaInterpreter.getCurrent().getConfig().getMaxRenderDepth(); + if (depth > maxRenderDepth) { + return deferredTags; + } + node + .getChildren() + .forEach(child -> deferredTags.addAll(getDeferredTags(child, depth + 1))); + return deferredTags; + } + + private static String rebuildTemplateForNodes(Node node) { + StringJoiner joiner = new StringJoiner(" "); + getDeferredTagsRecursively(node) + .stream() + .map(DeferredTag::getTag) + .forEach(joiner::add); + return joiner.toString(); + } + + private static Set findUsedProperties(String templateSource) { + Matcher matcher = TEMPLATE_TAG_PATTERN.matcher(templateSource); + Set tags = Sets.newHashSet(); + while (matcher.find()) { + tags.add(matcher.group(1)); + } + return tags; + } + + private static Set findSetProperties(String templateSource) { + Set tags = Sets.newHashSet(); + String[] lines = templateSource.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.contains(SetTag.TAG_NAME + " ")) { + tags.addAll(findUsedProperties(line)); + } + } + + return tags; + } + + private static Optional> getDeferredTags(Node deferredNode) { + if (deferredNode instanceof TextNode || deferredNode.getMaster() == null) { + return Optional.empty(); + } + + String nodeImage = deferredNode.getMaster().getImage(); + if (Strings.nullToEmpty(nodeImage).trim().isEmpty()) { + return Optional.empty(); + } + + Set deferredTags = new HashSet<>(); + deferredTags.add( + new DeferredTag().setTag(nodeImage).setNormalizedTag(getNormalizedTag(deferredNode)) + ); + + if (deferredNode instanceof TagNode) { + TagNode tagNode = (TagNode) deferredNode; + if (tagNode.getEndName() != null) { + String endTag = tagNode.reconstructEnd(); + deferredTags.add(new DeferredTag().setTag(endTag).setNormalizedTag(endTag)); + } + } + + return Optional.of(deferredTags); + } + + private static String getNormalizedTag(Node node) { + if (node instanceof ExpressionNode) { + return node.toString().replaceAll("\\s+", ""); + } + + return node.getMaster().getImage(); + } + + private static class DeferredTag { + + String tag; + String normalizedTag; + + public String getTag() { + return tag; + } + + public DeferredTag setTag(String tag) { + this.tag = tag; + return this; + } + + public String getNormalizedTag() { + return normalizedTag; + } + + public DeferredTag setNormalizedTag(String normalizedTag) { + this.normalizedTag = normalizedTag; + return this; + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java b/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java new file mode 100644 index 000000000..334886152 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java @@ -0,0 +1,502 @@ +package com.hubspot.jinjava.util; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.CannotReconstructValueException; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.LazyExpression; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.interpret.OneTimeReconstructible; +import com.hubspot.jinjava.interpret.RevertibleObject; +import com.hubspot.jinjava.lib.tag.ForTag; +import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Beta +public class EagerContextWatcher { + + /** + * Execute the specified functions within a protected context. + * Additionally, if the execution causes existing values on the context to become + * deferred, then their previous values will wrapped in a set + * tag that gets prepended to the returned result. + * The function is run in deferredExecutionMode=true, where the context needs to + * be protected from having values updated or set, + * such as when evaluating both the positive and negative nodes in an if statement. + * @param function Function to run within a "protected" child context + * @param interpreter JinjavaInterpreter to create a child from. + * @param eagerChildContextConfig Configuration for evaluation as defined in {@link EagerChildContextConfig} + * @return An EagerExecutionResult where: + * result is the string result of function. + * prefixToPreserveState is either blank or a set tag + * that preserves the state within the output for a second rendering pass. + */ + public static EagerExecutionResult executeInChildContext( + Function function, + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig + ) { + final EagerExecutionResult initialResult; + final Map speculativeBindings; + if (eagerChildContextConfig.checkForContextChanges) { + final Set> entrySet = interpreter.getContext().entrySet(); + final Map initiallyResolvedHashes = getInitiallyResolvedHashes( + entrySet, + interpreter.getContext() + ); + final Map initiallyResolvedAsStrings = + getInitiallyResolvedAsStrings(interpreter, entrySet, initiallyResolvedHashes); + initialResult = applyFunction(function, interpreter, eagerChildContextConfig); + speculativeBindings = + getAllSpeculativeBindings( + interpreter, + eagerChildContextConfig, + initiallyResolvedHashes, + initiallyResolvedAsStrings, + initialResult + ); + } else { + Set ignoredKeys = getAdditionalKeysToIgnore( + interpreter, + eagerChildContextConfig + ); + initialResult = applyFunction(function, interpreter, eagerChildContextConfig); + speculativeBindings = + getBasicSpeculativeBindings( + interpreter, + eagerChildContextConfig, + ignoredKeys, + initialResult + ); + } + return new EagerExecutionResult(initialResult.getResult(), speculativeBindings); + } + + private static EagerExecutionResult applyFunction( + Function function, + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig + ) { + // Don't create new call stacks to prevent hitting max recursion with this silent new scope + try (InterpreterScopeClosable c = interpreter.enterNonStackingScope()) { + if (eagerChildContextConfig.forceDeferredExecutionMode) { + interpreter.getContext().setDeferredExecutionMode(true); + } + interpreter + .getContext() + .setPartialMacroEvaluation(eagerChildContextConfig.partialMacroEvaluation); + return new EagerExecutionResult( + function.apply(interpreter), + eagerChildContextConfig.discardSessionBindings + ? new HashMap<>() + : interpreter.getContext().getSessionBindings() + ); + } + } + + private static Map getInitiallyResolvedAsStrings( + JinjavaInterpreter interpreter, + Set> entrySet, + Map initiallyResolvedHashes + ) { + Map initiallyResolvedAsStrings = new HashMap<>(); + // This creates a stringified snapshot of the context + // so it can be disabled via the config because it may cause performance issues. + Stream> entryStream = + (interpreter.getConfig().getExecutionMode().useEagerContextReverting() + ? entrySet + : interpreter.getContext().getCombinedScope().entrySet()).stream() + .filter(entry -> initiallyResolvedHashes.containsKey(entry.getKey())) + .filter(entry -> isResolvableForContextReverting(entry.getValue()) // TODO make this configurable + ); + entryStream.forEach(entry -> + cacheRevertibleObject( + interpreter, + initiallyResolvedHashes, + initiallyResolvedAsStrings, + entry + ) + ); + return initiallyResolvedAsStrings; + } + + private static Map getInitiallyResolvedHashes( + Set> entrySet, + Context context + ) { + Map mapOfHashes = new HashMap<>(); + entrySet + .stream() + .filter(entry -> + !MetaContextVariables.isMetaContextVariable(entry.getKey(), context) + ) + .filter(entry -> + !(entry.getValue() instanceof DeferredValue) && entry.getValue() != null + ) + .forEach(entry -> + mapOfHashes.put(entry.getKey(), getObjectOrHashCode(entry.getValue())) + ); // Avoid NPE when getObjectOrHashCode(entry.getValue()) is null) + return mapOfHashes; + } + + private static Set getAdditionalKeysToIgnore( + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig + ) { + // We don't need to reconstruct already deferred keys. + // This ternary expression is an optimization to call entrySet fewer times + return ( + interpreter.getContext().isDeferredExecutionMode() && + !eagerChildContextConfig.takeNewValue + ) + ? interpreter + .getContext() + .entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof DeferredValue) + .map(Entry::getKey) + .collect(Collectors.toSet()) + : Collections.emptySet(); + } + + private static Map getBasicSpeculativeBindings( + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig, + Set ignoredKeys, + EagerExecutionResult eagerExecutionResult + ) { + if (!eagerChildContextConfig.takeNewValue) { + eagerExecutionResult + .getSpeculativeBindings() + .putAll( + interpreter + .getContext() + .getScope() + .entrySet() + .stream() + .filter(entry -> + entry.getValue() instanceof OneTimeReconstructible && + !(((OneTimeReconstructible) entry.getValue()).isReconstructed()) + ) + .peek(entry -> + ((OneTimeReconstructible) entry.getValue()).setReconstructed(true) + ) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)) + ); + } + return eagerExecutionResult + .getSpeculativeBindings() + .entrySet() + .stream() + .filter(entry -> + !MetaContextVariables.isMetaContextVariable( + entry.getKey(), + interpreter.getContext() + ) + ) + .filter(entry -> !ignoredKeys.contains(entry.getKey())) + .filter(entry -> !ForTag.LOOP.equals(entry.getKey())) + .map(entry -> { + if ( + eagerExecutionResult.getResult().isFullyResolved() || + eagerChildContextConfig.takeNewValue + ) { + return entry; + } + + Object contextValue = interpreter.getContext().get(entry.getKey()); + if ( + contextValue instanceof DeferredValue && + ((DeferredValue) contextValue).getOriginalValue() != null + ) { + if ( + !eagerChildContextConfig.takeNewValue && + !EagerExpressionResolver.isResolvableObject( + ((DeferredValue) contextValue).getOriginalValue() + ) + ) { + throw new CannotReconstructValueException(entry.getKey()); + } + return new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), contextValue); + } + return null; + }) + .filter(Objects::nonNull) + .filter(entry -> entry.getValue() != null) + .filter(entry -> !isDeferredWithOriginalValueNull(entry.getValue())) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + private static Map getAllSpeculativeBindings( + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig, + Map initiallyResolvedHashes, + Map initiallyResolvedAsStrings, + EagerExecutionResult eagerExecutionResult + ) { + Map speculativeBindings = eagerExecutionResult + .getSpeculativeBindings() + .entrySet() + .stream() + .filter(entry -> + entry.getValue() != null && + !entry.getValue().equals(interpreter.getContext().get(entry.getKey())) + ) + .filter(entry -> + !(interpreter.getContext().get(entry.getKey()) instanceof DeferredValue) + ) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + speculativeBindings.putAll( + initiallyResolvedHashes + .keySet() + .stream() + .map(key -> + new AbstractMap.SimpleImmutableEntry<>(key, interpreter.getContext().get(key)) + ) + .filter(entry -> + !Objects.equals( + initiallyResolvedHashes.get(entry.getKey()), + getObjectOrHashCode(entry.getValue()) + ) + ) + .collect( + Collectors.toMap( + Entry::getKey, + entry -> + getOriginalValue( + interpreter, + eagerChildContextConfig, + initiallyResolvedHashes, + initiallyResolvedAsStrings, + entry, + eagerExecutionResult.getResult().isFullyResolved() + ) + ) + ) + ); + + speculativeBindings = + speculativeBindings + .entrySet() + .stream() + .filter(entry -> + !MetaContextVariables.isMetaContextVariable( + entry.getKey(), + interpreter.getContext() + ) + ) + .filter(entry -> entry.getValue() != null) + .filter(entry -> !isDeferredWithOriginalValueNull(entry.getValue())) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + return speculativeBindings; + } + + /** + * This is an optimization used to filter so that we don't reconstruct unnecessary tags like {@code {% set num = null %}} + * because {@code num} is already null when it hasn't been set to anything. + */ + private static boolean isDeferredWithOriginalValueNull(Object value) { + return ( + value instanceof DeferredValue && ((DeferredValue) value).getOriginalValue() == null + ); + } + + private static void cacheRevertibleObject( + JinjavaInterpreter interpreter, + Map initiallyResolvedHashes, + Map initiallyResolvedAsStrings, + Entry entry + ) { + RevertibleObject revertibleObject = interpreter + .getRevertibleObjects() + .get(entry.getKey()); + Object hashCode = initiallyResolvedHashes.get(entry.getKey()); + try { + if (revertibleObject == null || !hashCode.equals(revertibleObject.getHashCode())) { + revertibleObject = + new RevertibleObject( + hashCode, + PyishObjectMapper.getAsPyishStringOrThrow(entry.getValue()) + ); + interpreter.getRevertibleObjects().put(entry.getKey(), revertibleObject); + } + revertibleObject + .getPyishString() + .ifPresent(pyishString -> + initiallyResolvedAsStrings.put(entry.getKey(), pyishString) + ); + } catch (Exception e) { + interpreter + .getRevertibleObjects() + .put(entry.getKey(), new RevertibleObject(hashCode)); + } + } + + private static Object getOriginalValue( + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig, + Map initiallyResolvedHashes, + Map initiallyResolvedAsStrings, + Entry e, + boolean isFullyResolved + ) { + if (eagerChildContextConfig.takeNewValue || isFullyResolved) { + return e.getValue(); + } + + if ( + e.getValue() instanceof DeferredValue && + initiallyResolvedHashes + .get(e.getKey()) + .equals(getObjectOrHashCode(((DeferredValue) e.getValue()).getOriginalValue())) + ) { + return e.getValue(); + } + + // This is necessary if a state-changing function, such as .update() + // or .append() is run against a variable in the context. + // It will revert the effects when takeNewValue is false. + if (initiallyResolvedAsStrings.containsKey(e.getKey())) { + // convert to new list or map + try { + return interpreter.resolveELExpression( + initiallyResolvedAsStrings.get(e.getKey()), + interpreter.getLineNumber() + ); + } catch (DeferredValueException ignored) {} + } + + // Previous value could not be mapped to a string + throw new CannotReconstructValueException(e.getKey()); + } + + private static Object getObjectOrHashCode(Object o) { + if (o instanceof LazyExpression) { + o = ((LazyExpression) o).get(); + } + + if (o instanceof PyList && isResolvableForContextReverting(o)) { + return o.hashCode(); + } + if (o instanceof PyMap && isResolvableForContextReverting(o)) { + return o.hashCode() + ((PyMap) o).keySet().hashCode(); + } + return o; + } + + private static boolean isResolvableForContextReverting(Object o) { + return EagerExpressionResolver.isResolvableObject(o, 4, 400); + } + + public static class EagerChildContextConfig { + + private final boolean takeNewValue; + + private final boolean discardSessionBindings; + private final boolean partialMacroEvaluation; + + private final boolean checkForContextChanges; + private final boolean forceDeferredExecutionMode; + + private EagerChildContextConfig( + boolean takeNewValue, + boolean discardSessionBindings, + boolean partialMacroEvaluation, + boolean checkForContextChanges, + boolean forceDeferredExecutionMode + ) { + this.takeNewValue = takeNewValue; + this.discardSessionBindings = discardSessionBindings; + this.partialMacroEvaluation = partialMacroEvaluation; + this.checkForContextChanges = checkForContextChanges; + this.forceDeferredExecutionMode = forceDeferredExecutionMode; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + + private boolean takeNewValue; + + private boolean discardSessionBindings; + private boolean partialMacroEvaluation; + private boolean checkForContextChanges; + private boolean forceDeferredExecutionMode; + + private Builder() {} + + /** + * @param takeNewValue If a value is updated (not replaced) either take the new value or + * take the previous value and put it into the + * EagerExecutionResult.prefixToPreserveState. + */ + public Builder withTakeNewValue(boolean takeNewValue) { + this.takeNewValue = takeNewValue; + return this; + } + + /** + * @param discardSessionBindings Discard the session bindings from the child context + * created while executing the provided function. + */ + public Builder withDiscardSessionBindings(boolean discardSessionBindings) { + this.discardSessionBindings = discardSessionBindings; + return this; + } + + /** + * @param partialMacroEvaluation Allow macro functions to be partially evaluated rather than + * needing an explicit result during this render. + */ + public Builder withPartialMacroEvaluation(boolean partialMacroEvaluation) { + this.partialMacroEvaluation = partialMacroEvaluation; + return this; + } + + /** + * @param checkForContextChanges Hash and serialize values on the context to determine if changes + * have been made to any values on the context. + */ + public Builder withCheckForContextChanges(boolean checkForContextChanges) { + this.checkForContextChanges = checkForContextChanges; + return this; + } + + /** + * @param forceDeferredExecutionMode Start the evaluation of the specified function in deferred execution mode. + */ + public Builder withForceDeferredExecutionMode(boolean forceDeferredExecutionMode) { + this.forceDeferredExecutionMode = forceDeferredExecutionMode; + return this; + } + + public EagerChildContextConfig build() { + return new EagerChildContextConfig( + takeNewValue, + discardSessionBindings, + partialMacroEvaluation, + checkForContextChanges, + forceDeferredExecutionMode + ); + } + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/EagerExpressionResolver.java b/src/main/java/com/hubspot/jinjava/util/EagerExpressionResolver.java new file mode 100644 index 000000000..cd3b640d4 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/EagerExpressionResolver.java @@ -0,0 +1,556 @@ +package com.hubspot.jinjava.util; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Primitives; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.el.ext.ExtendedParser; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.ErrorHandlingStrategy; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.PartiallyDeferredValue; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.interpret.UnknownTokenException; +import com.hubspot.jinjava.objects.collections.ArrayBacked; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import com.hubspot.jinjava.tree.ExpressionNode; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult.ResolutionState; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.el.ELException; +import org.apache.commons.lang3.StringUtils; + +@Beta +public class EagerExpressionResolver { + + public static final String JINJAVA_NULL = "null"; + public static final String JINJAVA_EMPTY_STRING = "''"; + + private static final Set RESERVED_KEYWORDS = ImmutableSet.of( + "and", + "filter", + "in", + "is", + "not", + "or", + "pluralize", + "recursive", + "trans", + "null", + "true", + "false", + "__macros__", + ExtendedParser.INTERPRETER, + "exptest" + ); + + private static final Pattern NAMED_PARAMETER_KEY_PATTERN = Pattern.compile( + "[\\w.]+=([^=]|$)" + ); + private static final Pattern DICTIONARY_KEY_PATTERN = Pattern.compile("\\w+: "); + + /** + * Resolve the expression while handling deferred values. + * Returns a EagerExpressionResult object which either holds the fully resolved object or a + * partially resolved string as well as a set of any words that couldn't be resolved. + * If a DeferredParsingException is thrown, the expression was partially resolved. + * If a DeferredValueException is thrown, the expression could not be resolved at all. + *

+ * E.g with foo=3, bar=2: + * "range(0,foo)[-1] + deferred/bar" -> "2 + deferred/2" + */ + public static EagerExpressionResult resolveExpression( + String expression, + JinjavaInterpreter interpreter + ) { + boolean fullyResolved = false; + Set deferredWords = new HashSet<>(); + Object result; + try { + result = interpreter.resolveELExpression(expression, interpreter.getLineNumber()); + fullyResolved = true; + } catch (DeferredParsingException e) { + deferredWords.addAll(findDeferredWords(e.getDeferredEvalResult(), interpreter)); + result = e.getDeferredEvalResult().trim(); + // Throw base-class DeferredValueExceptions because only DeferredParsingExceptions are expected when parsing EL expressions + } catch (TemplateSyntaxException e) { + result = Collections.singletonList(null); + fullyResolved = true; + } + return new EagerExpressionResult( + result, + deferredWords, + fullyResolved ? ResolutionState.FULL : ResolutionState.PARTIAL + ); + } + + public static String getValueAsJinjavaStringSafe(Object val) { + try { + if (val == null) { + return JINJAVA_NULL; + } else if (isResolvableObject(val)) { + String pyishString = PyishObjectMapper.getAsPyishStringOrThrow(val); + if (pyishString.length() < 1048576) { // TODO maybe this should be configurable + return pyishString; + } + } + } catch (IOException | OutputTooBigException ignored) {} + throw new DeferredValueException("Can not convert deferred result to string"); + } + + // Find any unresolved variables, functions, etc in this expression to mark as deferred. + public static Set findDeferredWords( + String partiallyResolved, + JinjavaInterpreter interpreter + ) { + TokenScannerSymbols scannerSymbols = interpreter.getConfig().getTokenScannerSymbols(); + boolean nestedInterpretationEnabled = interpreter + .getConfig() + .isNestedInterpretationEnabled(); + FoundQuotedExpressionTags foundQuotedExpressionTags = new FoundQuotedExpressionTags(); + try ( + TemporaryValueClosable closable = interpreter + .getContext() + .withErrorHandlingStrategy( + ErrorHandlingStrategy + .builder() + .setFatalErrorStrategy( + ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.THROW_EXCEPTION + ) + .setNonFatalErrorStrategy( + ErrorHandlingStrategy.TemplateErrorTypeHandlingStrategy.IGNORE + ) + .build() + ) + ) { + Set words = new HashSet<>(); + char[] value = partiallyResolved.toCharArray(); + int prevQuotePos = -1; + int curPos = 0; + char c; + char prevChar = 0; + boolean inQuote = false; + char quoteChar = 0; + while (curPos < partiallyResolved.length()) { + c = value[curPos]; + if (inQuote) { + if (c == quoteChar && prevChar != '\\') { + if (nestedInterpretationEnabled) { + getDeferredWordsInsideNestedExpression( + interpreter, + scannerSymbols, + words, + partiallyResolved.substring(prevQuotePos, curPos + 1), + prevQuotePos, + foundQuotedExpressionTags + ); + } + inQuote = false; + prevQuotePos = curPos; + } + } else if ((c == '\'' || c == '"') && prevChar != '\\') { + inQuote = true; + quoteChar = c; + words.addAll( + findDeferredWordsInSubstring( + partiallyResolved, + prevQuotePos + 1, + curPos, + interpreter + ) + ); + prevQuotePos = curPos; + } + if (prevChar == '\\') { + // Double escapes cancel out. + prevChar = 0; + } else { + prevChar = c; + } + curPos++; + } + words.addAll( + findDeferredWordsInSubstring( + partiallyResolved, + prevQuotePos + 1, + curPos, + interpreter + ) + ); + + if (foundQuotedExpressionTags.fullTagMayExist()) { + throw new DeferredValueException( + "Cannot get words inside nested interpretation tags" + ); + } + return words; + } + } + + private static void getDeferredWordsInsideNestedExpression( + JinjavaInterpreter interpreter, + TokenScannerSymbols scannerSymbols, + Set words, + String quoted, + int offset, + FoundQuotedExpressionTags foundQuotedExpressionTags + ) { + if (foundQuotedExpressionTags.firstStartTagFoundLocation == null) { + int startWithIndex = quoted.indexOf(scannerSymbols.getExpressionStartWithTag()); + if (startWithIndex >= 0) { + foundQuotedExpressionTags.firstStartTagFoundLocation = startWithIndex + offset; + } + } + if (foundQuotedExpressionTags.firstStartTagFoundLocation != null) { + int endWithIndex = quoted.indexOf(scannerSymbols.getExpressionEndWithTag()); + if (endWithIndex >= 0) { + foundQuotedExpressionTags.lastEndTagFoundLocation = endWithIndex + offset; + } + } + + if ( + quoted.contains(scannerSymbols.getExpressionStart()) && + quoted.contains(scannerSymbols.getExpressionEnd()) + ) { + List expressionNodes = getExpressionNodes( + WhitespaceUtils.unquoteAndUnescape(quoted), + interpreter + ); + words.addAll( + expressionNodes + .stream() + .map(expressionNode -> ((ExpressionToken) expressionNode.getMaster()).getExpr()) + .map(expr -> findDeferredWords(expr, interpreter)) + .flatMap(Set::stream) + .collect(Collectors.toSet()) + ); + } + } + + private static List getExpressionNodes( + String input, + JinjavaInterpreter interpreter + ) { + Node root = interpreter.parse(input); + return getExpressionNodes(root).collect(Collectors.toList()); + } + + private static Stream getExpressionNodes(Node parent) { + if (parent instanceof ExpressionNode) { + return Stream.of((ExpressionNode) parent); + } + return parent + .getChildren() + .stream() + .flatMap(EagerExpressionResolver::getExpressionNodes); + } + + // Knowing that there are no quotes between start and end, + // split up the words in `partiallyResolved` and return whichever ones can't be resolved. + private static Set findDeferredWordsInSubstring( + String partiallyResolved, + int start, + int end, + JinjavaInterpreter interpreter + ) { + partiallyResolved = partiallyResolved.substring(start, end); + if (!interpreter.getConfig().getLegacyOverrides().isEvaluateMapKeys()) { + partiallyResolved = + DICTIONARY_KEY_PATTERN.matcher(partiallyResolved).replaceAll(" "); + } + return Arrays + .stream( + NAMED_PARAMETER_KEY_PATTERN + .matcher(partiallyResolved) + .replaceAll("$1") + .split("[^\\w.]") + ) + .filter(StringUtils::isNotBlank) + .filter(w -> shouldBeEvaluated(w, interpreter)) + .collect(Collectors.toSet()); + } + + public static boolean shouldBeEvaluated(String w, JinjavaInterpreter interpreter) { + try { + if (RESERVED_KEYWORDS.contains(w)) { + return false; + } + try { + Object val = interpreter.retraceVariable(w, interpreter.getLineNumber()); + if (val != null) { + // It's a variable that must now be deferred + return true; + } + } catch (UnknownTokenException e) { + // val is still null + } + // don't defer numbers, values such as true/false, etc. + return interpreter.resolveELExpressionSilently(w) == null; + } catch (ELException | DeferredValueException | TemplateSyntaxException e) { + return true; + } + } + + public static boolean isResolvableObject(Object val, int maxDepth, int maxSize) { + return isResolvableObjectRec(val, 0, maxDepth, maxSize); + } + + public static boolean isResolvableObject(Object val) { + return isResolvableObjectRec(val, 0, 10, Integer.MAX_VALUE); + } + + private static boolean isResolvableObjectRec( + Object val, + int depth, + int maxDepth, + int maxSize + ) { + if (depth > maxDepth) { + return false; + } + if (isPrimitive(val)) { + return true; + } + if (val instanceof ArrayBacked arrayBacked) { + val = arrayBacked.backingArray(); + } + try { + if (val instanceof Collection || val instanceof Map) { + int size = val instanceof Collection + ? ((Collection) val).size() + : ((Map) val).size(); + if (size == 0) { + return true; + } else if (size > maxSize) { + return false; + } + return ( + val instanceof Collection ? (Collection) val : ((Map) val).values() + ).stream() + .filter(Objects::nonNull) + .allMatch(item -> isResolvableObjectRec(item, depth + 1, maxDepth, maxSize)); + } else if (val.getClass().isArray()) { + if (((Object[]) val).length == 0) { + return true; + } else if (((Object[]) val).length > maxSize) { + return false; + } + return (Arrays.stream((Object[]) val)).filter(Objects::nonNull) + .allMatch(item -> isResolvableObjectRec(item, depth + 1, maxDepth, maxSize)); + } else if (val instanceof Optional) { + return ((Optional) val).map(item -> + isResolvableObjectRec(item, depth + 1, maxDepth, maxSize) + ) + .orElse(true); + } + } catch (DeferredValueException e) { + if (!(val instanceof PartiallyDeferredValue)) { + throw e; + } + } + return PyishSerializable.class.isAssignableFrom(val.getClass()); + } + + public static boolean isPrimitive(Object val) { + return ( + val == null || + Primitives.isWrapperType(val.getClass()) || + val instanceof String || + val instanceof Number + ); + } + + public static class EagerExpressionResult { + + private final Object resolvedObject; + private final Set deferredWords; + private final ResolutionState resolutionState; + + private EagerExpressionResult( + Object resolvedObject, + Set deferredWords, + ResolutionState resolutionState + ) { + this.resolvedObject = resolvedObject; + this.deferredWords = deferredWords; + this.resolutionState = resolutionState; + } + + /** + * Returns a string representation of the resolved expression. + * If there are multiple, they will be separated by commas, + * but not surrounded with brackets. + * @return String representation of the result. + */ + @Override + public String toString() { + return toString(false); + } + + /** + * When forOutput is true, the result will always be unquoted. + * @param forOutput Whether the result is going to be included in the final output, + * such as in an expression, or not such as when reconstructing tags. + * @return String representation of the result + */ + public String toString(boolean forOutput) { + if (!resolutionState.fullyResolved) { + return (String) resolvedObject; + } + if (resolvedObject == null) { + return forOutput ? "" : JINJAVA_EMPTY_STRING; + } + String asString; + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + if (forOutput && interpreter != null) { + asString = interpreter.getAsString(resolvedObject); + } else { + asString = PyishObjectMapper.getAsPyishString(resolvedObject); + } + if ( + !forOutput && + interpreter != null && + interpreter.getConfig().isNestedInterpretationEnabled() && + asString.contains( + interpreter.getConfig().getTokenScannerSymbols().getExpressionStart() + ) + ) { + Set dependentWords = EagerExpressionResolver.findDeferredWords( + asString, + interpreter + ); + if (!dependentWords.isEmpty()) { + deferredWords.addAll(dependentWords); + return asString; + } + } + return asString; + } + + public List toList() { + if (resolutionState.fullyResolved) { + if (resolvedObject instanceof List) { + return (List) resolvedObject; + } else { + return Collections.singletonList(resolvedObject); + } + } + throw new DeferredValueException("Object is not resolved"); + } + + public ResolutionState getResolutionState() { + return resolutionState; + } + + public boolean isFullyResolved() { + return resolutionState.fullyResolved; + } + + public Set getDeferredWords() { + return deferredWords; + } + + /** + * Method to wrap a string value in the EagerExpressionResult class. + * It is not evaluated, rather it's allows a the class to be manually + * built from a partially resolved string. + * @param resolvedString Partially resolved string to wrap. + * @return A EagerExpressionResult that {@link #toString()} returns resolvedString. + */ + public static EagerExpressionResult fromString(String resolvedString) { + return new EagerExpressionResult( + resolvedString, + Collections.emptySet(), + ResolutionState.PARTIAL + ); + } + + /** + * Method to wrap a string value in the EagerExpressionResult class. + * Manually provide whether the string has been fully resolved. + * @param resolvedString Partially or fully resolved string to wrap + * @param resolutionState Either FULL or PARTIAL + * @return A EagerExpressionResult that {@link #toString()} returns resolvedString. + */ + public static EagerExpressionResult fromString( + String resolvedString, + ResolutionState resolutionState + ) { + return new EagerExpressionResult( + resolvedString, + Collections.emptySet(), + resolutionState + ); + } + + /** + * Method to supply a string value to the EagerExpressionResult class. + * In the event that a DeferredValueException is thrown, the message will be the wrapped + * value, and the resolutionState will be NONE + * Manually provide whether the string has been fully resolved. + * @param stringSupplier Supplier function to run, which could potentially throw a DeferredValueException. + * @param interpreter The JinjavaInterpreter + * @return A EagerExpressionResult that wraps either + * stringSupplier.get() or the thrown DeferredValueException's message. + */ + public static EagerExpressionResult fromSupplier( + Supplier stringSupplier, + JinjavaInterpreter interpreter + ) { + try { + return EagerExpressionResult.fromString( + stringSupplier.get(), + interpreter.getContext().getDeferredTokens().isEmpty() + ? ResolutionState.FULL + : ResolutionState.PARTIAL + ); + } catch (DeferredValueException e) { + return EagerExpressionResult.fromString(e.getMessage(), ResolutionState.NONE); + } + } + + public enum ResolutionState { + FULL(true), + PARTIAL(false), + NONE(false); + + final boolean fullyResolved; + + ResolutionState(boolean fullyResolved) { + this.fullyResolved = fullyResolved; + } + } + } + + private static class FoundQuotedExpressionTags { + + Integer firstStartTagFoundLocation; + Integer lastEndTagFoundLocation; + + boolean fullTagMayExist() { + return ( + firstStartTagFoundLocation != null && + lastEndTagFoundLocation != null && + firstStartTagFoundLocation < lastEndTagFoundLocation + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/EagerReconstructionUtils.java b/src/main/java/com/hubspot/jinjava/util/EagerReconstructionUtils.java new file mode 100644 index 000000000..cef5d396f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/EagerReconstructionUtils.java @@ -0,0 +1,976 @@ +package com.hubspot.jinjava.util; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.el.ext.AbstractCallableMethod; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.Context.Library; +import com.hubspot.jinjava.interpret.DeferredLazyReferenceSource; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueShadow; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.MetaContextVariables; +import com.hubspot.jinjava.interpret.OneTimeReconstructible; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.lib.tag.AutoEscapeTag; +import com.hubspot.jinjava.lib.tag.DoTag; +import com.hubspot.jinjava.lib.tag.MacroTag; +import com.hubspot.jinjava.lib.tag.RawTag; +import com.hubspot.jinjava.lib.tag.SetTag; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; +import com.hubspot.jinjava.lib.tag.eager.EagerSetTagStrategy; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.objects.serialization.PyishBlockSetSerializable; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.output.DynamicRenderedOutputNode; +import com.hubspot.jinjava.tree.output.OutputList; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.tree.parse.NoteToken; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.util.EagerContextWatcher.EagerChildContextConfig; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Beta +public class EagerReconstructionUtils { + + /** + * @deprecated Use {@link EagerContextWatcher#executeInChildContext(Function, JinjavaInterpreter, EagerChildContextConfig)} + * Execute the specified functions within a protected context. + * Additionally, if the execution causes existing values on the context to become + * deferred, then their previous values will wrapped in a set + * tag that gets prepended to the returned result. + * The function is run in deferredExecutionMode=true, where the context needs to + * be protected from having values updated or set, + * such as when evaluating both the positive and negative nodes in an if statement. + * @param function Function to run within a "protected" child context + * @param interpreter JinjavaInterpreter to create a child from. + * @param takeNewValue If a value is updated (not replaced) either take the new value or + * take the previous value and put it into the + * EagerExecutionResult.prefixToPreserveState. + * @param partialMacroEvaluation Allow macro functions to be partially evaluated rather than + * needing an explicit result during this render. + * @param checkForContextChanges Set this to be true if executing function could + * cause changes to the context. Otherwise, a false value will + * speed up execution. + * @return An EagerExecutionResult where: + * result is the string result of function. + * prefixToPreserveState is either blank or a set tag + * that preserves the state within the output for a second rendering pass. + */ + @Deprecated + public static EagerExecutionResult executeInChildContext( + Function function, + JinjavaInterpreter interpreter, + boolean takeNewValue, + boolean partialMacroEvaluation, + boolean checkForContextChanges + ) { + return executeInChildContext( + function, + interpreter, + EagerChildContextConfig + .newBuilder() + .withTakeNewValue(takeNewValue) + .withForceDeferredExecutionMode(checkForContextChanges) + .withPartialMacroEvaluation(partialMacroEvaluation) + .build() + ); + } + + public static EagerExecutionResult executeInChildContext( + Function function, + JinjavaInterpreter interpreter, + EagerChildContextConfig eagerChildContextConfig + ) { + return EagerContextWatcher.executeInChildContext( + function, + interpreter, + eagerChildContextConfig + ); + } + + /** + * Reconstruct the macro functions and variables from the context before they + * get deferred. + * Those macro functions and variables found within {@code deferredWords} are + * reconstructed with {@link MacroTag}(s) and {@link SetTag}(s), respectively to + * preserve the context within the Jinjava template itself. + * @param deferredWords set of words that will need to be deferred based on the + * previously performed operation. + * @param interpreter the Jinjava interpreter. + * @return a Jinjava-syntax string of 0 or more macro tags and 0 or more set tags. + * @deprecated use {@link #hydrateReconstructionFromContextBeforeDeferring(PrefixToPreserveState, Set, JinjavaInterpreter)} + */ + @Deprecated + public static String reconstructFromContextBeforeDeferring( + Set deferredWords, + JinjavaInterpreter interpreter + ) { + return String.join( + "", + reconstructFromContextBeforeDeferringAsMap(deferredWords, interpreter).values() + ); + } + + /** + * Reconstruct the macro functions and variables from the context before they + * get deferred. + * Those macro functions and variables found within {@code deferredWords} are + * reconstructed with {@link MacroTag}(s) and {@link SetTag}(s), respectively to + * preserve the context within the Jinjava template itself. + * @param deferredWords set of words that will need to be deferred based on the + * previously performed operation. + * @param interpreter the Jinjava interpreter. + * @return a PrefixToPreserveState map of 0 or more macro tags and 0 or more set tags. + * @deprecated use {@link #hydrateReconstructionFromContextBeforeDeferring(PrefixToPreserveState, Set, JinjavaInterpreter)} + */ + @Deprecated + public static PrefixToPreserveState reconstructFromContextBeforeDeferringAsMap( + Set deferredWords, + JinjavaInterpreter interpreter + ) { + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + deferredWords, + interpreter, + 0 + ); + return prefixToPreserveState; + } + + /** + * Reconstruct the macro functions and variables from the context before they + * get deferred. + * Those macro functions and variables found within {@code deferredWords} are + * reconstructed with {@link MacroTag}(s) and {@link SetTag}(s), respectively to + * preserve the context within the Jinjava template itself. + * @param prefixToPreserveState This PrefixToPreserveState will be hydrated with the Macro tag images and set tag images + * @param deferredWords set of words that will need to be deferred based on the + * previously performed operation. + * @param interpreter the Jinjava interpreter. + * @return The PrefixToPreserveState to allow method chaining + */ + public static PrefixToPreserveState hydrateReconstructionFromContextBeforeDeferring( + PrefixToPreserveState prefixToPreserveState, + Set deferredWords, + JinjavaInterpreter interpreter + ) { + return hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + deferredWords, + interpreter, + 0 + ); + } + + private static PrefixToPreserveState hydrateReconstructionFromContextBeforeDeferring( + PrefixToPreserveState prefixToPreserveState, + Set deferredWords, + JinjavaInterpreter interpreter, + int depth + ) { + if (depth <= interpreter.getConfig().getMaxRenderDepth()) { + hydrateReconstructionOfMacroFunctionsBeforeDeferring( + prefixToPreserveState, + deferredWords, + interpreter + ); + Set deferredWordBases = filterToRelevantBases(deferredWords, interpreter); + if (deferredWordBases.isEmpty()) { + return prefixToPreserveState; + } + + return hydrateReconstructionOfVariablesBeforeDeferring( + prefixToPreserveState, + deferredWordBases, + interpreter, + depth + ); + } + return prefixToPreserveState; + } + + private static Set filterToRelevantBases( + Set deferredWords, + JinjavaInterpreter interpreter + ) { + Map combinedScope = interpreter.getContext().getCombinedScope(); + Set deferredWordBases = deferredWords + .stream() + .map(w -> w.split("\\.", 2)[0]) + .filter(combinedScope::containsKey) + .collect(Collectors.toSet()); + if (interpreter.getContext().isDeferredExecutionMode()) { + Context parent = interpreter.getContext().getParent(); + while (parent.isDeferredExecutionMode()) { + parent = parent.getParent(); + } + final Context finalParent = parent; + deferredWordBases = + deferredWordBases + .stream() + .filter(word -> { + Object parentValue = finalParent.get(word); + return ( + !(parentValue instanceof DeferredValue) && + interpreter.getContext().get(word) != finalParent.get(word) + ); + }) + .collect(Collectors.toSet()); + } + return deferredWordBases; + } + + /** + * Build macro tag images for any macro functions that are included in deferredWords + * and remove those macro functions from the deferredWords set. + * These macro functions are either global or local macro functions, with local + * meaning they've been imported under an alias such as "simple.multiply()". + * @param prefixToPreserveState This PrefixToPreserveState will be hydrated with the Macro tag images + * @param deferredWords Set of words that were encountered and their evaluation has + * to be deferred for a later render. + * @param interpreter The Jinjava interpreter. + * @return The PrefixToPreserveState to allow method chaining + */ + private static PrefixToPreserveState hydrateReconstructionOfMacroFunctionsBeforeDeferring( + PrefixToPreserveState prefixToPreserveState, + Set deferredWords, + JinjavaInterpreter interpreter + ) { + Set toRemove = new HashSet<>(); + Map macroFunctions = deferredWords + .stream() + .filter(w -> !prefixToPreserveState.containsKey(w)) + .filter(w -> !interpreter.getContext().containsKey(w)) + .map(w -> interpreter.getContext().getGlobalMacro(w)) + .filter(Objects::nonNull) + .filter(macroFunction -> !macroFunction.isCaller()) + .collect(Collectors.toMap(AbstractCallableMethod::getName, Function.identity())); + for (String word : deferredWords) { + if (word.contains(".")) { + interpreter + .getContext() + .getLocalMacro(word) + .ifPresent(macroFunction -> macroFunctions.put(word, macroFunction)); + } + } + + Map reconstructedMacros = macroFunctions + .entrySet() + .stream() + .peek(entry -> toRemove.add(entry.getKey())) + .peek(entry -> entry.getValue().setDeferred(true)) + .map(entry -> + new AbstractMap.SimpleImmutableEntry<>( + entry.getKey(), + EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString( + ((EagerMacroFunction) entry.getValue()).reconstructImage(entry.getKey()) + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .build() + ) + ) + ) + .collect( + Collectors.toMap(Entry::getKey, entry -> entry.getValue().asTemplateString()) + ); + prefixToPreserveState.withAll(reconstructedMacros); + // Remove macro functions from the set because they've been fully processed now. + deferredWords.removeAll(toRemove); + return prefixToPreserveState; + } + + private static PrefixToPreserveState hydrateReconstructionOfVariablesBeforeDeferring( + PrefixToPreserveState prefixToPreserveState, + Set deferredWords, + JinjavaInterpreter interpreter, + int depth + ) { + deferredWords + .stream() + .filter(w -> + !MetaContextVariables.isMetaContextVariable(w, interpreter.getContext()) + ) + .filter(w -> !prefixToPreserveState.containsKey(w)) + .map(word -> + new AbstractMap.SimpleImmutableEntry<>(word, interpreter.getContext().get(word)) + ) + .filter(entry -> + entry.getValue() != null && !(entry.getValue() instanceof DeferredValue) + ) + .forEach(entry -> + hydrateBlockOrInlineSetTagRecursively( + prefixToPreserveState, + entry.getKey(), + entry.getValue(), + interpreter, + depth + ) + ); + return prefixToPreserveState; + } + + public static String buildBlockOrInlineSetTag( + String name, + Object value, + JinjavaInterpreter interpreter + ) { + return buildBlockOrInlineSetTag(name, value, interpreter, false); + } + + public static String buildBlockOrInlineSetTagAndRegisterDeferredToken( + String name, + Object value, + JinjavaInterpreter interpreter + ) { + return buildBlockOrInlineSetTag(name, value, interpreter, true); + } + + public static PrefixToPreserveState hydrateBlockOrInlineSetTagRecursively( + PrefixToPreserveState prefixToPreserveState, + String name, + Object value, + JinjavaInterpreter interpreter + ) { + return hydrateBlockOrInlineSetTagRecursively( + prefixToPreserveState, + name, + value, + interpreter, + 0 + ); + } + + private static PrefixToPreserveState hydrateBlockOrInlineSetTagRecursively( + PrefixToPreserveState prefixToPreserveState, + String name, + Object value, + JinjavaInterpreter interpreter, + int depth + ) { + if ( + value instanceof DeferredValue && + !(value instanceof PyishBlockSetSerializable || value instanceof PyishSerializable) + ) { + value = ((DeferredValue) value).getOriginalValue(); + } + if (value instanceof PyishBlockSetSerializable) { + prefixToPreserveState.put( + name, + buildBlockSetTag( + name, + ((PyishBlockSetSerializable) value).getBlockSetBody(), + interpreter, + false + ) + ); + return prefixToPreserveState; + } + String pyishStringRepresentation = PyishObjectMapper.getAsPyishString(value); + + if ( + depth < interpreter.getConfig().getMaxRenderDepth() && + interpreter.getConfig().isNestedInterpretationEnabled() + ) { + Set dependentWords = EagerExpressionResolver.findDeferredWords( + pyishStringRepresentation, + interpreter + ); + if (!dependentWords.isEmpty()) { + hydrateReconstructionFromContextBeforeDeferring( + prefixToPreserveState, + dependentWords, + interpreter, + depth + 1 + ); + } + } + prefixToPreserveState.put( + name, + buildSetTag(ImmutableMap.of(name, pyishStringRepresentation), interpreter, false) + ); + return prefixToPreserveState; + } + + public static String buildBlockOrInlineSetTag( + String name, + Object value, + JinjavaInterpreter interpreter, + boolean registerDeferredToken + ) { + if ( + value instanceof DeferredValue && + !(value instanceof PyishBlockSetSerializable || value instanceof PyishSerializable) + ) { + value = ((DeferredValue) value).getOriginalValue(); + } + if (value instanceof PyishBlockSetSerializable) { + return buildBlockSetTag( + name, + ((PyishBlockSetSerializable) value).getBlockSetBody(), + interpreter, + registerDeferredToken + ); + } + return buildSetTag( + ImmutableMap.of(name, PyishObjectMapper.getAsPyishString(value)), + interpreter, + registerDeferredToken + ); + } + + /** + * Build the image for a {@link SetTag} which preserves the values of objects on the context + * for a later rendering pass. The set tag will set the keys to the values within + * the {@code deferredValuesToSet} Map. + * @param deferredValuesToSet Map that specifies what the context objects should be set + * to in the returned image. + * @param interpreter The Jinjava interpreter. + * @param registerDeferredToken Whether or not to register the returned {@link SetTag} + * image as an {@link DeferredToken}. + * @return A jinjava-syntax string that is the image of a set tag that will + * be executed at a later time. + */ + public static String buildSetTag( + Map deferredValuesToSet, + JinjavaInterpreter interpreter, + boolean registerDeferredToken + ) { + if (deferredValuesToSet.isEmpty()) { + return ""; + } + Map> disabled = interpreter.getConfig().getDisabled(); + if (disabled != null) { + Set disabledTags = disabled.get(Library.TAG); + if (disabledTags != null && disabledTags.contains(SetTag.TAG_NAME)) { + throw new DisabledException("set tag disabled"); + } + } + + StringJoiner vars = new StringJoiner(","); + StringJoiner values = new StringJoiner(","); + List varsRequiringSuffix = new ArrayList<>(); + deferredValuesToSet.forEach((key, value) -> { + // This ensures they are properly aligned to each other. + vars.add(key); + values.add(value); + if (!MetaContextVariables.isTemporaryImportAlias(value)) { + varsRequiringSuffix.add(key); + } + }); + LengthLimitingStringJoiner result = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ); + result + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(SetTag.TAG_NAME) + .add(vars.toString()) + .add("=") + .add(values.toString()) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()); + String image = result.toString(); + String suffix = EagerSetTagStrategy.getSuffixToPreserveState( + varsRequiringSuffix, + interpreter + ); + // Don't defer if we're sticking with the new value + if (registerDeferredToken) { + return ( + new PrefixToPreserveState( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(image, TagToken.class, interpreter) + .addSetDeferredWords(deferredValuesToSet.keySet()) + .build() + ) + ) + + image + + suffix + ); + } + return (image + suffix); + } + + /** + * Build the image for a block {@link SetTag} and body to preserve the values of an object + * on the context for a later rendering pass. + * @param name The name of the variable to set. + * @param value The string value, potentially containing jinja code to put in the set tag block. + * @param interpreter The Jinjava interpreter. + * @param registerDeferredToken Whether to register the returned {@link SetTag} + * token as an {@link DeferredToken}. + * @return A jinjava-syntax string that is the image of a block set tag that will + * be executed at a later time. + */ + public static String buildBlockSetTag( + String name, + String value, + JinjavaInterpreter interpreter, + boolean registerDeferredToken + ) { + Map> disabled = interpreter.getConfig().getDisabled(); + if (disabled != null) { + Set disabledTags = disabled.get(Library.TAG); + if (disabledTags != null && disabledTags.contains(SetTag.TAG_NAME)) { + throw new DisabledException("set tag disabled"); + } + } + + LengthLimitingStringJoiner blockSetTokenBuilder = new LengthLimitingStringJoiner( + interpreter.getConfig().getMaxOutputSize(), + " " + ); + StringJoiner endTokenBuilder = new StringJoiner(" "); + blockSetTokenBuilder + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(SetTag.TAG_NAME) + .add(name) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()); + endTokenBuilder + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add("end" + SetTag.TAG_NAME) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()); + String image = blockSetTokenBuilder + value + endTokenBuilder; + String suffix = EagerSetTagStrategy.getSuffixToPreserveState( + new String[] { name }, + interpreter + ); + if (registerDeferredToken) { + return ( + new PrefixToPreserveState( + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage( + blockSetTokenBuilder.toString(), + TagToken.class, + interpreter + ) + .addSetDeferredWords(Stream.of(name)) + .build() + ) + ) + + image + + suffix + ); + } + return image + suffix; + } + + public static String buildDoUpdateTag( + String name, + String updateString, + JinjavaInterpreter interpreter + ) { + Map> disabled = interpreter.getConfig().getDisabled(); + if (disabled != null) { + Set disabledTags = disabled.get(Library.TAG); + if (disabledTags != null && disabledTags.contains(DoTag.TAG_NAME)) { + throw new DisabledException("do tag disabled"); + } + } + return new LengthLimitingStringJoiner(interpreter.getConfig().getMaxOutputSize(), " ") + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(DoTag.TAG_NAME) + .add(String.format("%s.update(%s)", name, updateString)) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()) + .toString(); + } + + public static String reconstructEnd(TagNode tagNode) { + return String.format( + "%s %s %s", + tagNode.getSymbols().getExpressionStartWithTag(), + tagNode.getEndName(), + tagNode.getSymbols().getExpressionEndWithTag() + ); + } + + public static String wrapInRawIfNeeded(String output, JinjavaInterpreter interpreter) { + if ( + interpreter.getConfig().getExecutionMode().isPreserveRawTags() && + !interpreter.getContext().isUnwrapRawOverride() + ) { + if ( + output.contains( + interpreter.getConfig().getTokenScannerSymbols().getExpressionStart() + ) || + output.contains( + interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag() + ) + ) { + output = wrapInTag(output, RawTag.TAG_NAME, interpreter, false); + } + } + return output; + } + + public static String wrapInAutoEscapeIfNeeded( + String output, + JinjavaInterpreter interpreter + ) { + if ( + interpreter.getContext().isAutoEscape() && + (interpreter.getContext().getParent() == null || + !interpreter.getContext().getParent().isAutoEscape()) + ) { + output = wrapInTag(output, AutoEscapeTag.TAG_NAME, interpreter, false); + } + return output; + } + + /** + * Wrap the string output in a specified block-type tag. + * @param body The string body to wrap. + * @param tagNameToWrap The name of the tag which will wrap around the {@param body}. + * @param interpreter The Jinjava interpreter. + * @param registerDeferredToken Whether to register the returned Tag + * token as an {@link DeferredToken}. + * @return A jinjava-syntax string that is the image of a block set tag that will + * be executed at a later time. + */ + public static String wrapInTag( + String body, + String tagNameToWrap, + JinjavaInterpreter interpreter, + boolean registerDeferredToken + ) { + Map> disabled = interpreter.getConfig().getDisabled(); + if (disabled != null) { + Set disabledTags = disabled.get(Library.TAG); + if (disabledTags != null && disabledTags.contains(tagNameToWrap)) { + throw new DisabledException(String.format("%s tag disabled", tagNameToWrap)); + } + } + StringJoiner startTokenBuilder = new StringJoiner(" "); + StringJoiner endTokenBuilder = new StringJoiner(" "); + startTokenBuilder + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add(tagNameToWrap) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()); + endTokenBuilder + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag()) + .add("end" + tagNameToWrap) + .add(interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag()); + String image = startTokenBuilder + body + endTokenBuilder; + if (registerDeferredToken) { + EagerReconstructionUtils.handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage(startTokenBuilder.toString(), TagToken.class, interpreter) + .build() + ); + } + return image; + } + + /** + * Surround the {@param body} with notes to provide identifying information on what {@param body} is. + * If {@param noteIdentifier} is {@code foo} and {@param body} is {@code {{ bar }}}, the result will be: + *

+ * {@code {# foo #}{{ bar }}{# endfoo #}} + * @param body The string body to wrap. + * @param noteIdentifier The identifier for the note. + * @param interpreter The Jinjava interpreter. + * @return A block surrounded with labelled notes + */ + public static String labelWithNotes( + String body, + String noteIdentifier, + JinjavaInterpreter interpreter + ) { + return ( + getStartLabel(noteIdentifier, interpreter.getConfig().getTokenScannerSymbols()) + + body + + getEndLabel(noteIdentifier, interpreter.getConfig().getTokenScannerSymbols()) + ); + } + + public static String getStartLabel(String noteIdentifier, TokenScannerSymbols symbols) { + StringJoiner stringJoiner = new StringJoiner(" "); + return stringJoiner + .add(symbols.getOpeningComment()) + .add("Start Label: ") + .add(noteIdentifier) + .add(symbols.getClosingComment()) + .toString(); + } + + public static String getEndLabel(String noteIdentifier, TokenScannerSymbols symbols) { + StringJoiner stringJoiner = new StringJoiner(" "); + return stringJoiner + .add(symbols.getOpeningComment()) + .add("End Label: ") + .add(noteIdentifier) + .add(symbols.getClosingComment()) + .toString(); + } + + public static String wrapInChildScope(String toWrap, JinjavaInterpreter interpreter) { + return ( + String.format( + "%s for __ignored__ in [0] %s", + interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag(), + interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag() + ) + + toWrap + + String.format( + "%s endfor %s", + interpreter.getConfig().getTokenScannerSymbols().getExpressionStartWithTag(), + interpreter.getConfig().getTokenScannerSymbols().getExpressionEndWithTag() + ) + ); + } + + public static Boolean isDeferredExecutionMode() { + return JinjavaInterpreter + .getCurrentMaybe() + .map(interpreter -> interpreter.getContext().isDeferredExecutionMode()) + .orElse(false); + } + + public static PrefixToPreserveState deferWordsAndReconstructReferences( + JinjavaInterpreter interpreter, + Set wordsToDefer + ) { + if (!wordsToDefer.isEmpty()) { + wordsToDefer = + wordsToDefer + .stream() + .filter(key -> !(interpreter.getContext().get(key) instanceof DeferredValue)) + .collect(Collectors.toSet()); + PrefixToPreserveState prefixToPreserveState = new PrefixToPreserveState(); + if (!wordsToDefer.isEmpty()) { + prefixToPreserveState.withAllInFront( + handleDeferredTokenAndReconstructReferences( + interpreter, + DeferredToken + .builderFromImage("", NoteToken.class, interpreter) + .addUsedDeferredWords(wordsToDefer) + .build() + ) + ); + } + return prefixToPreserveState; + } + return new PrefixToPreserveState(); + } + + public static Map handleDeferredTokenAndReconstructReferences( + JinjavaInterpreter interpreter, + DeferredToken deferredToken + ) { + deferredToken.addTo(interpreter.getContext()); + return reconstructDeferredReferences( + interpreter, + deferredToken.getUsedDeferredBases() + ); + } + + public static Map reconstructDeferredReferences( + JinjavaInterpreter interpreter, + Set usedDeferredBases + ) { + return interpreter + .getContext() + .getScope() + .entrySet() + .stream() + .filter(entry -> + entry.getValue() instanceof OneTimeReconstructible && + !((OneTimeReconstructible) entry.getValue()).isReconstructed() + ) + .filter(entry -> + // Always reconstruct the DeferredLazyReferenceSource, but only reconstruct DeferredLazyReferences when they are used + entry.getValue() instanceof DeferredLazyReferenceSource || + usedDeferredBases.contains(entry.getKey()) + ) + .peek(entry -> ((OneTimeReconstructible) entry.getValue()).setReconstructed(true)) + .map(entry -> + new AbstractMap.SimpleImmutableEntry<>( + entry.getKey(), + PyishObjectMapper.getAsPyishString( + ((DeferredValue) entry.getValue()).getOriginalValue() + ) + ) + ) + .sorted((a, b) -> + a.getValue().equals(b.getKey()) ? 1 : b.getValue().equals(a.getKey()) ? -1 : 0 + ) + .collect( + Collectors.toMap( + Entry::getKey, + entry -> + buildSetTag( + Collections.singletonMap(entry.getKey(), entry.getValue()), + interpreter, + false + ), + (a, b) -> b, + LinkedHashMap::new + ) + ); + } + + /** + * Reset variables to what they were before running the latest execution represented by {@param eagerExecutionResult}. + * Then re-defer those variables and reconstruct deferred lazy references to them. + * This method is needed in 2 circumstances: + *

+ * * When doing some eager execution and then needing to repeat the same execution in deferred execution mode. + *

+ * * When rendering logic which takes place in its own child scope (for tag, macro function, set block) and there + * are speculative bindings. + * These must be deferred and the execution must run again, so they don't get reconstructed + * within the child scope, and can instead be reconstructed in their original scopes. + * @param interpreter The JinjavaInterpreter + * @param eagerExecutionResult The execution result which contains information about which bindings were modified + * during the execution. + * @return + */ + public static PrefixToPreserveState resetAndDeferSpeculativeBindings( + JinjavaInterpreter interpreter, + EagerExecutionResult eagerExecutionResult + ) { + return deferWordsAndReconstructReferences( + interpreter, + resetSpeculativeBindings(interpreter, eagerExecutionResult) + ); + } + + public static Set resetSpeculativeBindings( + JinjavaInterpreter interpreter, + EagerExecutionResult result + ) { + result + .getSpeculativeBindings() + .forEach((k, v) -> { + if (v instanceof DeferredValue) { + v = ((DeferredValue) v).getOriginalValue(); + } + replace(interpreter.getContext(), k, v); + }); + return result.getSpeculativeBindings().keySet(); + } + + private static void replace(Context context, String k, Object v) { + if (context == null) { + return; + } + Object replaced = context.getScope().replace(k, v); + if (replaced == null) { + replace(context.getParent(), k, v); + } else if (replaced instanceof DeferredValueShadow) { + context.getScope().remove(k); + replace(context.getParent(), k, v); + } + } + + public static void commitSpeculativeBindings( + JinjavaInterpreter interpreter, + EagerExecutionResult result + ) { + result + .getSpeculativeBindings() + .entrySet() + .stream() + // Filter DeferredValueShadow because these are just used to mark that a value became deferred within this scope + // The original key will be a DeferredValueImpl already on its original scope + .filter(entry -> !(entry.getValue() instanceof DeferredValueShadow)) + .forEach(entry -> interpreter.getContext().put(entry.getKey(), entry.getValue())); + } + + public static void reconstructPathAroundBlock( + DynamicRenderedOutputNode prefix, + OutputList blockValueBuilder, + JinjavaInterpreter interpreter + ) { + String blockPath = RelativePathResolver.getCurrentPathFromStackOrKey(interpreter); + String tempVarName = MetaContextVariables.getTemporaryCurrentPathVarName(blockPath); + prefix.setValue( + buildSetTag( + ImmutableMap.of( + tempVarName, + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + PyishObjectMapper.getAsPyishString(blockPath) + ), + interpreter, + false + ) + ); + blockValueBuilder.addNode( + new RenderedOutputNode( + buildSetTag( + ImmutableMap.of( + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + tempVarName, + tempVarName, + "null" + ), + interpreter, + false + ) + ) + ); + } + + public static String wrapPathAroundText( + String text, + String newPath, + JinjavaInterpreter interpreter + ) { + String tempVarName = MetaContextVariables.getTemporaryCurrentPathVarName(newPath); + return ( + buildSetTag( + ImmutableMap.of( + tempVarName, + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + PyishObjectMapper.getAsPyishString(newPath) + ), + interpreter, + false + ) + + text + + buildSetTag( + ImmutableMap.of( + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + tempVarName, + tempVarName, + "null" + ), + interpreter, + false + ) + ); + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/ForLoop.java b/src/main/java/com/hubspot/jinjava/util/ForLoop.java index bb84b5e84..e233e7b95 100644 --- a/src/main/java/com/hubspot/jinjava/util/ForLoop.java +++ b/src/main/java/com/hubspot/jinjava/util/ForLoop.java @@ -15,11 +15,10 @@ **********************************************************************/ package com.hubspot.jinjava.util; -import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; - import java.util.Iterator; public class ForLoop implements Iterator { + private static final int NULL_VALUE = Integer.MIN_VALUE; private int index = -1; @@ -29,10 +28,12 @@ public class ForLoop implements Iterator { private int length = NULL_VALUE; private boolean first = true; private boolean last; + private boolean continued; + private boolean broken; private int depth; - private Iterator it; + private final Iterator it; public ForLoop(Iterator ite, int len) { length = len; @@ -46,6 +47,8 @@ public ForLoop(Iterator ite, int len) { last = false; } it = ite; + continued = false; + broken = false; } public ForLoop(Iterator ite) { @@ -58,10 +61,16 @@ public ForLoop(Iterator ite) { revcounter = 2; last = true; } + continued = false; + broken = false; } @Override public Object next() { + if (broken) { + return null; + } + continued = false; Object res; if (it.hasNext()) { index++; @@ -111,23 +120,14 @@ public int getRevindex() { } public int getRevindex0() { - if (revindex == NULL_VALUE) { - ENGINE_LOG.warn("can't compute items' length while looping."); - } return revindex; } public int getRevcounter() { - if (revcounter == NULL_VALUE) { - ENGINE_LOG.warn("can't compute items' length while looping."); - } return revcounter; } public int getLength() { - if (revcounter == NULL_VALUE) { - ENGINE_LOG.warn("can't compute items' length while looping."); - } return length; } @@ -139,8 +139,24 @@ public boolean isLast() { return last; } + public boolean isContinued() { + return continued; + } + + public void doContinue() { + continued = true; + } + + public void doBreak() { + continued = true; + broken = true; + } + @Override public boolean hasNext() { + if (broken) { + return false; + } return it.hasNext(); } @@ -154,4 +170,8 @@ public void remove() { throw new UnsupportedOperationException(); } + @Override + public String toString() { + return ""; + } } diff --git a/src/main/java/com/hubspot/jinjava/util/HasObjectTruthValue.java b/src/main/java/com/hubspot/jinjava/util/HasObjectTruthValue.java new file mode 100644 index 000000000..1667525d2 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/HasObjectTruthValue.java @@ -0,0 +1,5 @@ +package com.hubspot.jinjava.util; + +public interface HasObjectTruthValue { + boolean getObjectTruthValue(); +} diff --git a/src/main/java/com/hubspot/jinjava/util/HelperStringTokenizer.java b/src/main/java/com/hubspot/jinjava/util/HelperStringTokenizer.java index f4c26cdb3..0fade94c3 100644 --- a/src/main/java/com/hubspot/jinjava/util/HelperStringTokenizer.java +++ b/src/main/java/com/hubspot/jinjava/util/HelperStringTokenizer.java @@ -1,24 +1,23 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.util; -import java.util.List; - import com.google.common.collect.AbstractIterator; import com.google.common.collect.Lists; +import java.util.List; /** * Whitespace and comma as separator quote to accept them as normal char @@ -28,18 +27,20 @@ */ public class HelperStringTokenizer extends AbstractIterator { - private char[] helpers; + private final char[] value; + private final int length; + private int currPost = 0; private int tokenStart = 0; - private int length = 0; private char lastChar = ' '; private boolean useComma = false; private char quoteChar = 0; private boolean inQuote = false; + private boolean isEscaped = false; - public HelperStringTokenizer(String tobeToken) { - helpers = tobeToken.toCharArray(); - length = tobeToken.length(); + public HelperStringTokenizer(String s) { + value = s.toCharArray(); + length = value.length; } /** @@ -59,7 +60,7 @@ protected String computeNext() { String token; while (currPost < length) { token = makeToken(); - lastChar = helpers[currPost - 1]; + lastChar = value[currPost - 1]; if (token != null) { return token; } @@ -70,8 +71,9 @@ protected String computeNext() { } private String makeToken() { - char c = helpers[currPost++]; - if (c == '"' || c == '\'') { + char c = value[currPost++]; + + if ((c == '"' || c == '\'') && !isEscaped) { if (inQuote) { if (quoteChar == c) { inQuote = false; @@ -81,6 +83,9 @@ private String makeToken() { quoteChar = c; } } + + isEscaped = (c == '\\' && !isEscaped); + if ((Character.isWhitespace(c) || (useComma && c == ',')) && !inQuote) { return newToken(); } @@ -91,7 +96,7 @@ private String makeToken() { } private String getEndToken() { - return String.copyValueOf(helpers, tokenStart, currPost - tokenStart); + return String.copyValueOf(value, tokenStart, currPost - tokenStart); } private String newToken() { @@ -101,11 +106,10 @@ private String newToken() { return null; } // startChar = -1;//change to save quote in helper - return String.copyValueOf(helpers, lastStart, currPost - lastStart - 1); + return String.copyValueOf(value, lastStart, currPost - lastStart - 1); } public List allTokens() { return Lists.newArrayList(this); } - } diff --git a/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringBuilder.java b/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringBuilder.java new file mode 100644 index 000000000..850d7df5f --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringBuilder.java @@ -0,0 +1,92 @@ +package com.hubspot.jinjava.util; + +import com.hubspot.jinjava.interpret.OutputTooBigException; +import java.io.Serializable; +import java.util.stream.IntStream; + +public class LengthLimitingStringBuilder + implements Serializable, CharSequence, Appendable { + + private static final long serialVersionUID = -1891922886257965755L; + + private final StringBuilder builder; + private long length = 0; + private final long maxLength; + + public LengthLimitingStringBuilder(long maxLength) { + builder = new StringBuilder(); + this.maxLength = maxLength; + } + + @Override + public int length() { + return builder.length(); + } + + @Override + public char charAt(int index) { + return builder.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return builder.subSequence(start, end); + } + + @Override + public String toString() { + return builder.toString(); + } + + @Override + public IntStream chars() { + return builder.chars(); + } + + @Override + public IntStream codePoints() { + return builder.codePoints(); + } + + public void append(Object obj) { + append(String.valueOf(obj)); + } + + @Override + public LengthLimitingStringBuilder append(CharSequence csq) { + int csqLength = 4; // null + if (csq != null) { + csqLength = csq.length(); + } + length += csqLength; + checkLength(); + builder.append(csq); + return this; + } + + @Override + public Appendable append(CharSequence csq, int start, int end) { + int csqLength = 4; // null + if (csq != null) { + csqLength = end - start; + } + length += csqLength; + checkLength(); + builder.append(csq, start, end); + return this; + } + + @Override + public Appendable append(char c) { + length++; + checkLength(); + builder.append(c); + return this; + } + + private void checkLength() { + if (maxLength > 0 && length > maxLength) { + throw new OutputTooBigException(maxLength, length); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringJoiner.java b/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringJoiner.java new file mode 100644 index 000000000..c4f278b80 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/LengthLimitingStringJoiner.java @@ -0,0 +1,40 @@ +package com.hubspot.jinjava.util; + +import com.hubspot.jinjava.interpret.OutputTooBigException; +import java.util.StringJoiner; + +public class LengthLimitingStringJoiner { + + private final StringJoiner joiner; + private final int delimiterLength; + private final long maxLength; + + public LengthLimitingStringJoiner(long maxLength, CharSequence delimiter) { + joiner = new StringJoiner(delimiter); + delimiterLength = delimiter.length(); + this.maxLength = maxLength; + } + + public int length() { + return joiner.length(); + } + + public LengthLimitingStringJoiner add(Object obj) { + return add(String.valueOf(obj)); + } + + public LengthLimitingStringJoiner add(CharSequence newElement) { + long newLength = + joiner.length() + newElement.length() + (joiner.length() > 0 ? delimiterLength : 0); + if (maxLength > 0 && newLength > maxLength) { + throw new OutputTooBigException(maxLength, newLength); + } + joiner.add(newElement); + return this; + } + + @Override + public String toString() { + return joiner.toString(); + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/Logging.java b/src/main/java/com/hubspot/jinjava/util/Logging.java index dee138731..5662042f6 100644 --- a/src/main/java/com/hubspot/jinjava/util/Logging.java +++ b/src/main/java/com/hubspot/jinjava/util/Logging.java @@ -19,7 +19,5 @@ import org.slf4j.LoggerFactory; public interface Logging { - Logger ENGINE_LOG = LoggerFactory.getLogger("jinjava"); - } diff --git a/src/main/java/com/hubspot/jinjava/util/ObjectIterator.java b/src/main/java/com/hubspot/jinjava/util/ObjectIterator.java index 7ba55c04a..3df8413bc 100644 --- a/src/main/java/com/hubspot/jinjava/util/ObjectIterator.java +++ b/src/main/java/com/hubspot/jinjava/util/ObjectIterator.java @@ -1,36 +1,35 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.util; -import java.util.ArrayList; +import com.google.common.collect.Iterators; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.Map; -import com.google.common.collect.Iterators; - public final class ObjectIterator { - private ObjectIterator() { - } + private ObjectIterator() {} @SuppressWarnings("unchecked") public static ForLoop getLoop(Object obj) { if (obj == null) { - return new ForLoop(new ArrayList().iterator(), 0); + return new ForLoop(Collections.emptyIterator(), 0); } // collection if (obj instanceof Collection) { @@ -44,7 +43,16 @@ public static ForLoop getLoop(Object obj) { } // map if (obj instanceof Map) { - Collection clt = ((Map) obj).values(); + boolean iterateOverMapKeys = + JinjavaInterpreter.getCurrent() != null && + JinjavaInterpreter + .getCurrent() + .getConfig() + .getLegacyOverrides() + .isIterateOverMapKeys(); + Collection clt = iterateOverMapKeys + ? ((Map) obj).keySet() + : ((Map) obj).values(); return new ForLoop(clt.iterator(), clt.size()); } // iterable,iterator @@ -55,9 +63,6 @@ public static ForLoop getLoop(Object obj) { return new ForLoop((Iterator) obj); } // others - ArrayList res = new ArrayList(); - res.add(obj); - return new ForLoop(res.iterator(), 1); + return new ForLoop(Iterators.singletonIterator(obj), 1); } - } diff --git a/src/main/java/com/hubspot/jinjava/util/ObjectTruthValue.java b/src/main/java/com/hubspot/jinjava/util/ObjectTruthValue.java index fb35f00ac..e4a08387b 100644 --- a/src/main/java/com/hubspot/jinjava/util/ObjectTruthValue.java +++ b/src/main/java/com/hubspot/jinjava/util/ObjectTruthValue.java @@ -15,34 +15,43 @@ **********************************************************************/ package com.hubspot.jinjava.util; +import com.hubspot.jinjava.objects.SafeString; import java.lang.reflect.Array; import java.util.Collection; import java.util.Map; public final class ObjectTruthValue { - private ObjectTruthValue() { - } + private ObjectTruthValue() {} public static boolean evaluate(Object object) { - if (object == null) { return false; } + if (object instanceof HasObjectTruthValue) { + return ((HasObjectTruthValue) object).getObjectTruthValue(); + } + if (object instanceof Boolean) { Boolean b = (Boolean) object; return b.booleanValue(); } if (object instanceof Number) { - return ((Number) object).intValue() != 0; + return ((Number) object).doubleValue() != 0; } if (object instanceof String) { return !"".equals(object) && !"false".equalsIgnoreCase((String) object); } + if (object instanceof SafeString) { + return ( + !"".equals(object.toString()) && !"false".equalsIgnoreCase(object.toString()) + ); + } + if (object.getClass().isArray()) { return Array.getLength(object) != 0; } @@ -57,5 +66,4 @@ public static boolean evaluate(Object object) { return true; } - } diff --git a/src/main/java/com/hubspot/jinjava/util/ObjectValue.java b/src/main/java/com/hubspot/jinjava/util/ObjectValue.java deleted file mode 100644 index 241228075..000000000 --- a/src/main/java/com/hubspot/jinjava/util/ObjectValue.java +++ /dev/null @@ -1,29 +0,0 @@ -/********************************************************************** -Copyright (c) 2014 HubSpot 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.hubspot.jinjava.util; - -import java.util.Objects; - -public final class ObjectValue { - - private ObjectValue() { - } - - public static String printable(Object variable) { - return Objects.toString(variable, ""); - } - -} diff --git a/src/main/java/com/hubspot/jinjava/util/PrefixToPreserveState.java b/src/main/java/com/hubspot/jinjava/util/PrefixToPreserveState.java new file mode 100644 index 000000000..999cfb051 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/PrefixToPreserveState.java @@ -0,0 +1,42 @@ +package com.hubspot.jinjava.util; + +import com.google.common.annotations.Beta; +import com.google.common.collect.ForwardingMap; +import java.util.LinkedHashMap; +import java.util.Map; + +@Beta +public class PrefixToPreserveState extends ForwardingMap { + + private Map reconstructedValues; + + public PrefixToPreserveState() { + reconstructedValues = new LinkedHashMap<>(); + } + + public PrefixToPreserveState(Map reconstructedValues) { + this.reconstructedValues = reconstructedValues; + } + + @Override + protected Map delegate() { + return reconstructedValues; + } + + @Override + public String toString() { + return String.join("", reconstructedValues.values()); + } + + public PrefixToPreserveState withAllInFront(Map toInsert) { + Map newMap = new LinkedHashMap<>(toInsert); + reconstructedValues.forEach(newMap::putIfAbsent); + reconstructedValues = newMap; + return this; + } + + public PrefixToPreserveState withAll(Map toPut) { + putAll(toPut); + return this; + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/RenderLimitUtils.java b/src/main/java/com/hubspot/jinjava/util/RenderLimitUtils.java new file mode 100644 index 000000000..61f2de594 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/util/RenderLimitUtils.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.util; + +import com.hubspot.jinjava.JinjavaConfig; + +public class RenderLimitUtils { + + public static long clampProvidedRenderLimitToConfig( + long providedLimit, + JinjavaConfig jinjavaConfig + ) { + long configMaxOutput = jinjavaConfig.getMaxOutputSize(); + + if (configMaxOutput <= 0) { + return providedLimit; + } + + if (providedLimit <= 0) { + return configMaxOutput; + } + + return Math.min(providedLimit, configMaxOutput); + } +} diff --git a/src/main/java/com/hubspot/jinjava/util/ScopeMap.java b/src/main/java/com/hubspot/jinjava/util/ScopeMap.java index 92aea6e13..6208db8fc 100644 --- a/src/main/java/com/hubspot/jinjava/util/ScopeMap.java +++ b/src/main/java/com/hubspot/jinjava/util/ScopeMap.java @@ -1,13 +1,13 @@ package com.hubspot.jinjava.util; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import javax.annotation.Nonnull; public class ScopeMap implements Map { @@ -19,7 +19,7 @@ public ScopeMap() { } public ScopeMap(ScopeMap parent) { - this.scope = new HashMap(); + this.scope = new HashMap<>(); this.parent = parent; } @@ -90,16 +90,56 @@ public V get(Object key) { @Override public V put(K key, V value) { + if (value == this) { + throw new IllegalArgumentException( + String.format("attempt to put on map with key '%s' and value of itself", key) + ); + } return scope.put(key, value); } + @Override + public boolean replace(K key, V oldValue, V newValue) { + boolean replaced = scope.replace(key, oldValue, newValue); + if (replaced) { + return true; + } + if (parent != null) { + return parent.replace(key, oldValue, newValue); + } + return false; + } + + @Override + public V replace(K key, V value) { + V val = scope.replace(key, value); + if (val != null) { + return val; + } + if (parent != null) { + return parent.replace(key, value); + } + return null; + } + @Override public V remove(Object key) { return scope.remove(key); } @Override - public void putAll(Map m) { + public void putAll(@Nonnull Map m) { + for (Entry entry : m.entrySet()) { + if (entry.getValue() == this) { + throw new IllegalArgumentException( + String.format( + "attempt to putAll on map with key '%s' and value of itself", + entry.getKey() + ) + ); + } + } + scope.putAll(m); } @@ -109,6 +149,7 @@ public void clear() { } @Override + @Nonnull public Set keySet() { Set keys = new HashSet<>(); @@ -122,6 +163,7 @@ public Set keySet() { } @Override + @Nonnull public Collection values() { Set> entrySet = entrySet(); Collection values = new ArrayList<>(entrySet.size()); @@ -134,19 +176,23 @@ public Collection values() { } @Override - @SuppressFBWarnings(justification = "using overridden get() to do scoped retrieve with parent fallback", - value = "WMI_WRONG_MAP_ITERATOR") + @SuppressFBWarnings( + justification = "using overridden get() to do scoped retrieve with parent fallback", + value = "WMI_WRONG_MAP_ITERATOR" + ) + @Nonnull public Set> entrySet() { Set> entries = new HashSet<>(); for (K key : keySet()) { - entries.add(new ScopeMapEntry(key, get(key), this)); + entries.add(new ScopeMapEntry<>(key, get(key), this)); } return entries; } public static class ScopeMapEntry implements Map.Entry { + private final Map map; private final K key; private V value; @@ -169,12 +215,9 @@ public V getValue() { @Override public V setValue(V value) { - V old = value; this.value = value; map.put(key, value); - return old; + return value; } - } - } diff --git a/src/main/java/com/hubspot/jinjava/util/Variable.java b/src/main/java/com/hubspot/jinjava/util/Variable.java index ccdf04cc5..5649ce408 100644 --- a/src/main/java/com/hubspot/jinjava/util/Variable.java +++ b/src/main/java/com/hubspot/jinjava/util/Variable.java @@ -1,50 +1,46 @@ /********************************************************************** -Copyright (c) 2014 HubSpot 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. + * Copyright (c) 2014 HubSpot 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.hubspot.jinjava.util; -import java.util.Collections; -import java.util.List; - import com.google.common.base.Splitter; import com.google.common.collect.Lists; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Collections; +import java.util.List; public class Variable { + private static final Splitter DOT_SPLITTER = Splitter.on('.'); - private JinjavaInterpreter interpreter; + private final JinjavaInterpreter interpreter; - private String name; - private List chainList; + private final String name; + private final List chainList; public Variable(JinjavaInterpreter interpreter, String variable) { this.interpreter = interpreter; - split(variable); - } - private void split(String variable) { - if (!variable.contains(".")) { + if (variable.indexOf('.') == -1) { name = variable; chainList = Collections.emptyList(); - return; + } else { + List parts = Lists.newArrayList(DOT_SPLITTER.split(variable)); + name = parts.get(0); + chainList = parts.subList(1, parts.size()); } - - List parts = Lists.newArrayList(DOT_SPLITTER.split(variable)); - name = parts.get(0); - chainList = parts.subList(1, parts.size()); } public String getName() { diff --git a/src/main/java/com/hubspot/jinjava/util/WhitespaceUtils.java b/src/main/java/com/hubspot/jinjava/util/WhitespaceUtils.java index c14441b0c..b3a7c6e17 100644 --- a/src/main/java/com/hubspot/jinjava/util/WhitespaceUtils.java +++ b/src/main/java/com/hubspot/jinjava/util/WhitespaceUtils.java @@ -1,9 +1,13 @@ package com.hubspot.jinjava.util; +import com.google.common.base.Strings; import com.hubspot.jinjava.interpret.InterpretException; +import javax.annotation.Nullable; public final class WhitespaceUtils { + private static final char[] QUOTE_CHARS = new char[] { '\'', '"' }; + public static boolean startsWith(String s, String prefix) { if (s == null) { return false; @@ -12,8 +16,7 @@ public static boolean startsWith(String s, String prefix) { for (int i = 0; i < s.length(); i++) { if (Character.isWhitespace(s.charAt(i))) { continue; - } - else { + } else { return s.regionMatches(i, prefix, 0, prefix.length()); } } @@ -29,8 +32,7 @@ public static boolean endsWith(String s, String suffix) { for (int i = s.length() - 1; i >= 0; i--) { if (Character.isWhitespace(s.charAt(i))) { continue; - } - else { + } else { return s.regionMatches(i - suffix.length() + 1, suffix, 0, suffix.length()); } } @@ -48,8 +50,7 @@ public static boolean isQuoted(String s) { throw new InterpretException("Unbalanced quotes: " + s); } return true; - } - else if (startsWith(s, "\"")) { + } else if (startsWith(s, "\"")) { if (!endsWith(s, "\"")) { throw new InterpretException("Unbalanced quotes: " + s); } @@ -58,19 +59,70 @@ else if (startsWith(s, "\"")) { return false; } + public static boolean isExpressionQuoted(String s) { + if (Strings.isNullOrEmpty(s)) { + return false; + } + char[] charArray = s.trim().toCharArray(); + if (charArray.length == 1) { + return false; + } + char quoteChar = 0; + for (char c : QUOTE_CHARS) { + if (charArray[0] == c) { + quoteChar = c; + break; + } + } + if (charArray[charArray.length - 1] != quoteChar) { + return false; + } + char prevChar = 0; + for (int i = 1; i < charArray.length - 1; i++) { + if (charArray[i] == quoteChar && prevChar != '\\') { + return false; + } + if (prevChar == '\\') { + // Double escapes cancel out. + prevChar = 0; + } else { + prevChar = charArray[i]; + } + } + return prevChar != '\\'; + } + public static String unquote(String s) { if (s == null) { return ""; } if (startsWith(s, "'")) { return unwrap(s, "'", "'"); - } - else if (startsWith(s, "\"")) { + } else if (startsWith(s, "\"")) { return unwrap(s, "\"", "\""); } return s.trim(); } + public static String unquoteAndUnescape(String s) { + if (Strings.isNullOrEmpty(s)) { + return ""; + } + if (!isExpressionQuoted(s)) { + return s.trim(); + } + + if (startsWith(s, "'")) { + s = unwrap(s, "'", "'"); + } else if (startsWith(s, "\"")) { + s = unwrap(s, "\"", "\""); + } else { + return s.trim(); + } + // Since we're unquoting, we can unescape the quote characters in the string. + return s.replace("\\\"", "\"").replace("\\'", "'").replace("\\\\", "\\"); + } + public static String unwrap(String s, String prefix, String suffix) { int start = 0, end = s.length() - 1; @@ -91,6 +143,16 @@ public static String unwrap(String s, String prefix, String suffix) { return s.substring(start + prefix.length(), end - suffix.length() + 1); } - private WhitespaceUtils() { + @Nullable + public static StringBuilder quoteIfNotNull(CharSequence charSequence) { + if (charSequence != null) { + return new StringBuilder(charSequence.length() + 2) + .append('\'') + .append(charSequence) + .append('\''); + } + return null; } + + private WhitespaceUtils() {} } diff --git a/src/test/java/com/hubspot/jinjava/BaseInterpretingTest.java b/src/test/java/com/hubspot/jinjava/BaseInterpretingTest.java new file mode 100644 index 000000000..7fdf9c4c4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/BaseInterpretingTest.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava; + +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.junit.After; +import org.junit.Before; + +public abstract class BaseInterpretingTest extends BaseJinjavaTest { + + public JinjavaInterpreter interpreter; + public Context context; + + @Before + @Override + public void baseSetup() { + super.baseSetup(); + interpreter = new JinjavaInterpreter(jinjava.newInterpreter()); + context = interpreter.getContext(); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void baseTeardown() { + JinjavaInterpreter.popCurrent(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/BaseJinjavaTest.java b/src/test/java/com/hubspot/jinjava/BaseJinjavaTest.java new file mode 100644 index 000000000..f7b3244a4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/BaseJinjavaTest.java @@ -0,0 +1,42 @@ +package com.hubspot.jinjava; + +import com.hubspot.jinjava.el.ext.AllowlistMethodValidator; +import com.hubspot.jinjava.el.ext.AllowlistReturnTypeValidator; +import com.hubspot.jinjava.el.ext.MethodValidatorConfig; +import com.hubspot.jinjava.el.ext.ReturnTypeValidatorConfig; +import org.junit.Before; + +public abstract class BaseJinjavaTest { + + public static final AllowlistMethodValidator METHOD_VALIDATOR = + AllowlistMethodValidator.create( + MethodValidatorConfig + .builder() + .addDefaultAllowlistGroups() + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + "com.hubspot.jinjava.testobjects." + ) + .build() + ); + public static final AllowlistReturnTypeValidator RETURN_TYPE_VALIDATOR = + AllowlistReturnTypeValidator.create( + ReturnTypeValidatorConfig + .builder() + .addDefaultAllowlistGroups() + .addAllowedCanonicalClassPrefixes("com.hubspot.jinjava.testobjects.") + .build() + ); + public Jinjava jinjava; + + @Before + public void baseSetup() { + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + } + + public static JinjavaConfig.Builder newConfigBuilder() { + return JinjavaConfig + .builder() + .withMethodValidator(METHOD_VALIDATOR) + .withReturnTypeValidator(RETURN_TYPE_VALIDATOR); + } +} diff --git a/src/test/java/com/hubspot/jinjava/EagerTest.java b/src/test/java/com/hubspot/jinjava/EagerTest.java new file mode 100644 index 000000000..5ad3a4e9a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/EagerTest.java @@ -0,0 +1,1857 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.io.Resources; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import com.hubspot.jinjava.mode.DefaultExecutionMode; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.mode.ExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.util.DeferredValueUtils; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerTest { + + private JinjavaInterpreter interpreter; + private Jinjava jinjava; + private ExpectedTemplateInterpreter expectedTemplateInterpreter; + Context globalContext = new Context(); + Context localContext; // ref to context created with global as parent + + @Before + public void setup() { + setupWithExecutionMode(EagerExecutionMode.instance()); + } + + protected void setupWithExecutionMode(ExecutionMode executionMode) { + JinjavaInterpreter.popCurrent(); + jinjava = new Jinjava(); + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(fullName), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(executionMode) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(20) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + globalContext, + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + expectedTemplateInterpreter = + ExpectedTemplateInterpreter.withSensibleCurrentPath(jinjava, interpreter, "eager"); + localContext = interpreter.getContext(); + + localContext.put("deferred", DeferredValue.instance()); + localContext.put("resolved", "resolvedValue"); + localContext.put("dict", ImmutableSet.of("a", "b", "c")); + localContext.put("dict2", ImmutableSet.of("e", "f", "g")); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + try { + assertThat(interpreter.getErrors()).isEmpty(); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itReconstructsMapWithNullValues() { + interpreter.render("{% set foo = {'foo': null} %}"); + assertThat(interpreter.getContext().get("foo")).isInstanceOf(Map.class); + assertThat((Map) interpreter.getContext().get("foo")).hasSize(1); + } + + @Test + public void itDefersNodeWhenModifiedInForLoop() { + assertThat( + interpreter.render( + "{% set bar = 'bar' %}{% set foo = 0 %}{% for i in deferred %}{{ bar ~ foo ~ bar }} {% set foo = foo + 1 %}{% endfor %}" + ) + ) + .isEqualTo( + "{% set foo = 0 %}{% for i in deferred %}{{ 'bar' ~ foo ~ 'bar' }} {% set foo = foo + 1 %}{% endfor %}" + ); + } + + @Test + public void checkAssumptions() { + // Just checking assumptions + String output = interpreter.render("deferred"); + assertThat(output).isEqualTo("deferred"); + + output = interpreter.render("resolved"); + assertThat(output).isEqualTo("resolved"); + + output = interpreter.render("a {{resolved}} b"); + assertThat(output).isEqualTo("a resolvedValue b"); + assertThat(interpreter.getErrors()).isEmpty(); + + assertThat(localContext.getParent()).isEqualTo(globalContext); + } + + @Test + public void itDefersSimpleExpressions() { + String output = interpreter.render("a {{ deferred }} b"); + assertThat(output).isEqualTo("a {{ deferred }} b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersWholeNestedExpressions() { + String output = interpreter.render("a {{ deferred.nested }} b"); + assertThat(output).isEqualTo("a {{ deferred.nested }} b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersAsLittleAsPossible() { + String output = interpreter.render("a {{ deferred }} {{resolved}} b"); + assertThat(output).isEqualTo("a {{ deferred }} resolvedValue b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesIfTag() { + String output = interpreter.render( + "{% if deferred %}{{resolved}}{% else %}b{% endif %}" + ); + assertThat(output).isEqualTo("{% if deferred %}resolvedValue{% else %}b{% endif %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itEagerlyResolvesNestedIfTag() { + String output = interpreter.render( + "{% if deferred %}{% if resolved %}{{resolved}}{% endif %}{% else %}b{% endif %}" + ); + assertThat(output).isEqualTo("{% if deferred %}resolvedValue{% else %}b{% endif %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + /** + * This may or may not be desirable behaviour. + */ + @Test + public void itDoesntPreservesElseIfTag() { + String output = interpreter.render("{% if true %}a{% elif deferred %}b{% endif %}"); + assertThat(output).isEqualTo("a"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolvesIfTagWherePossible() { + String output = interpreter.render("{% if true %}{{ deferred }}{% endif %}"); + assertThat(output).isEqualTo("{{ deferred }}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolveEqualToInOrCondition() { + String output = interpreter.render( + "{% if 'a' is equalto 'b' or 'a' is equalto 'a' %}{{ deferred }}{% endif %}" + ); + assertThat(output).isEqualTo("{{ deferred }}"); + } + + @Test + public void itPreserveDeferredVariableResolvingEqualToInOrCondition() { + String inputOutputExpected = + "{% if 'a' is equalto 'b' or 'a' is equalto deferred %}preserved{% endif %}"; + String output = interpreter.render(inputOutputExpected); + + assertThat(output) + .isEqualTo( + "{% if false || exptest:equalto.evaluate('a', ____int3rpr3t3r____, deferred) %}preserved{% endif %}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + localContext.put("deferred", "a"); + assertThat(interpreter.render(output)).isEqualTo("preserved"); + } + + @Test + @SuppressWarnings("unchecked") + public void itDoesNotResolveForTagDeferredBlockInside() { + String output = interpreter.render( + "{% for item in dict %}{% if item == deferred %} equal {% else %} not equal {% endif %}{% endfor %}" + ); + StringBuilder expected = new StringBuilder(); + expected.append("{% for __ignored__ in [0] %}"); + for (String item : (Set) localContext.get("dict")) { + expected + .append(String.format("{%% if '%s' == deferred %%}", item)) + .append(" equal {% else %} not equal {% endif %}"); + } + expected.append("{% endfor %}"); + assertThat(output).isEqualTo(expected.toString()); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + public void itDoesNotResolveForTagDeferredBlockNestedInside() { + String output = interpreter.render( + "{% for item in dict %}{% if item == 'a' %} equal {% if item == deferred %}{% endif %}{% else %} not equal {% endif %}{% endfor %}" + ); + StringBuilder expected = new StringBuilder(); + expected.append("{% for __ignored__ in [0] %}"); + for (String item : (Set) localContext.get("dict")) { + if (item.equals("a")) { + expected.append(" equal {% if 'a' == deferred %}{% endif %}"); + } else { + expected.append(" not equal "); + } + } + expected.append("{% endfor %}"); + assertThat(output).isEqualTo(expected.toString()); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + public void itDoesNotResolveNestedForTags() { + String output = interpreter.render( + "{% for item in dict %}{% for item2 in dict2 %}{% if item2 == 'e' %} equal {% if item2 == deferred %}{% endif %}{% else %} not equal {% endif %}{% endfor %}{% endfor %}" + ); + + StringBuilder expected = new StringBuilder(); + expected.append("{% for __ignored__ in [0] %}"); + for (String item : (Set) localContext.get("dict")) { + expected.append("{% for __ignored__ in [0] %}"); + for (String item2 : (Set) localContext.get("dict2")) { + if (item2.equals("e")) { + expected.append(" equal {% if 'e' == deferred %}{% endif %}"); + } else { + expected.append(" not equal "); + } + } + expected.append("{% endfor %}"); + } + expected.append("{% endfor %}"); + assertThat(output).isEqualTo(expected.toString()); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesNestedExpressions() { + localContext.put("nested", "some {{ deferred }} value"); + String output = interpreter.render("Test {{nested}}"); + assertThat(output).isEqualTo("Test some {{ deferred }} value"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesForTag() { + String output = interpreter.render( + "{% for item in deferred %}{{ item.name }}last{% endfor %}" + ); + assertThat(output) + .isEqualTo("{% for item in deferred %}{{ item.name }}last{% endfor %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesFilters() { + String output = interpreter.render("{{ deferred|capitalize }}"); + assertThat(output) + .isEqualTo("{{ filter:capitalize.filter(deferred, ____int3rpr3t3r____) }}"); + assertThat(interpreter.getErrors()).isEmpty(); + localContext.put("deferred", "foo"); + assertThat(interpreter.render(output)).isEqualTo("Foo"); + } + + @Test + public void itPreservesFunctions() { + String output = interpreter.render("{{ deferred|datetimeformat('%B %e, %Y') }}"); + assertThat(output) + .isEqualTo( + "{{ filter:datetimeformat.filter(deferred, ____int3rpr3t3r____, '%B %e, %Y') }}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesRandomness() { + String output = interpreter.render("{{ [1, 2, 3]|shuffle }}"); + assertThat(output) + .isEqualTo("{{ filter:shuffle.filter([1, 2, 3], ____int3rpr3t3r____) }}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersMacro() { + localContext.put("padding", 0); + localContext.put("added_padding", 10); + String deferredOutput = interpreter.render( + expectedTemplateInterpreter.getDeferredFixtureTemplate("deferred-macro.jinja") + ); + Object padding = localContext.get("padding"); + assertThat(padding).isInstanceOf(DeferredValue.class); + assertThat(((DeferredValue) padding).getOriginalValue()).isEqualTo(10); + + localContext.put("padding", ((DeferredValue) padding).getOriginalValue()); + localContext.put("added_padding", 10); + // not deferred anymore + localContext.put("deferred", 5); + localContext.remove("int"); + localContext.getGlobalMacro("inc_padding").setDeferred(false); + + String output = interpreter.render(deferredOutput); + assertThat(output.replace("\n", "")).isEqualTo("0,10,15,25"); + } + + @Test + public void itDefersAllVariablesUsedInDeferredNode() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "vars-in-deferred-node.jinja" + ); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + Object varInScope = localContext.get("varUsedInForScope"); + assertThat(varInScope).isInstanceOf(DeferredValue.class); + DeferredValue varInScopeDeferred = (DeferredValue) varInScope; + assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("outside if statement"); + + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender).isEqualTo("outside if statement entered if statement"); + + localContext.put("deferred", DeferredValue.instance()); + localContext.put("resolved", "resolvedValue"); + } + + @Test + public void itDefersDependantVariables() { + String template = ""; + template += + "{% set resolved_variable = 'resolved' %} {% set deferred_variable = deferred + '-' + resolved_variable %}"; + template += "{{ deferred_variable }}"; + interpreter.render(template); + localContext.get("resolved_variable"); + } + + @Test + public void itDefersVariablesComparedAgainstDeferredVals() { + String template = ""; + template += "{% set testVar = 'testvalue' %}"; + template += "{% if deferred == testVar %} true {% else %} false {% endif %}"; + + localContext.put("deferred", DeferredValue.instance("testvalue")); + String output = interpreter.render(template); + assertThat(output.trim()) + .isEqualTo("{% if deferred == 'testvalue' %} true {% else %} false {% endif %}"); + + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender.trim()).isEqualTo("true"); + } + + @Test + public void itDoesNotPutDeferredVariablesOnGlobalContext() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "set-within-lower-scope.jinja" + ); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + interpreter.render(template); + assertThat(globalContext).isEmpty(); + } + + @Test + public void itPutsDeferredVariablesOnParentScopes() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "set-within-lower-scope.jinja" + ); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender.trim()).isEqualTo("inside first scope".trim()); + } + + @Test + public void puttingDeferredVariablesOnParentScopesDoesNotBreakSetTag() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "set-within-lower-scope-twice.jinja" + ); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender.trim()) + .isEqualTo("inside first scopeinside first scope2".trim()); + } + + @Test + public void itMarksVariablesSetInDeferredBlockAsDeferred() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "set-in-deferred.jinja" + ); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + Context context = localContext; + assertThat(localContext).containsKey("varSetInside"); + Object varSetInside = localContext.get("varSetInside"); + assertThat(varSetInside).isInstanceOf(DeferredValue.class); + assertThat(output).contains("{{ varSetInside }}").contains("xyz"); + assertThat(context.get("a")).isInstanceOf(DeferredValue.class); + assertThat(context.get("b")).isInstanceOf(DeferredValue.class); + assertThat(context.get("c")).isInstanceOf(DeferredValue.class); + } + + @Test + public void itMarksVariablesUsedAsMapKeysAsDeferred() { + String template = expectedTemplateInterpreter.getDeferredFixtureTemplate( + "deferred-map-access.jinja" + ); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + localContext.put("deferredValue2", DeferredValue.instance("key")); + ImmutableMap> map = ImmutableMap.of( + "map", + ImmutableMap.of("key", "value") + ); + localContext.put("imported", map); + + String output = interpreter.render(template); + assertThat(localContext).containsKey("deferredValue2"); + Object deferredValue2 = localContext.get("deferredValue2"); + localContext + .getDeferredNodes() + .forEach(node -> + DeferredValueUtils.findAndMarkDeferredProperties(localContext, node) + ); + assertThat(deferredValue2).isInstanceOf(DeferredValue.class); + assertThat(output) + .contains( + "{% set varSetInside = {'key': 'value'} [deferredValue2.nonexistentprop] %}" + ); + } + + @Test + public void itEagerlyDefersSet() { + localContext.put("bar", true); + expectedTemplateInterpreter.assertExpectedOutput("eagerly-defers-set/test"); + } + + @Test + public void itEvaluatesNonEagerSet() { + expectedTemplateInterpreter.assertExpectedOutput("evaluates-non-eager-set/test"); + assertThat( + localContext + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getSetDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .isEmpty(); + assertThat( + localContext + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .contains("deferred"); + } + + @Test + public void itDefersOnImmutableMode() { + expectedTemplateInterpreter.assertExpectedOutput("defers-on-immutable-mode/test"); + } + + @Test + public void itDoesntAffectParentFromEagerIf() { + expectedTemplateInterpreter.assertExpectedOutput( + "doesnt-affect-parent-from-eager-if/test" + ); + } + + @Test + public void itDefersEagerChildScopedVars() { + expectedTemplateInterpreter.assertExpectedOutput( + "defers-eager-child-scoped-vars/test" + ); + } + + @Test + public void itSetsMultipleVarsDeferredInChild() { + expectedTemplateInterpreter.assertExpectedOutput( + "sets-multiple-vars-deferred-in-child/test" + ); + } + + @Test + public void itSetsMultipleVarsDeferredInChildSecondPass() { + localContext.put("deferred", true); + expectedTemplateInterpreter.assertExpectedOutput( + "sets-multiple-vars-deferred-in-child/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "sets-multiple-vars-deferred-in-child/test.expected" + ); + } + + @Test + public void itDoesntDoubleAppendInDeferredIfTag() { + expectedTemplateInterpreter.assertExpectedOutput( + "doesnt-double-append-in-deferred-if-tag/test" + ); + } + + @Test + public void itPrependsSetIfStateChanges() { + expectedTemplateInterpreter.assertExpectedOutput( + "prepends-set-if-state-changes/test" + ); + } + + @Test + public void itHandlesLoopVarAgainstDeferredInLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-loop-var-against-deferred-in-loop/test" + ); + } + + @Test + public void itHandlesLoopVarAgainstDeferredInLoopSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-loop-var-against-deferred-in-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-loop-var-against-deferred-in-loop/test.expected" + ); + } + + @Test + public void itDefersMacroForDoAndPrint() { + localContext.put("my_list", new PyList(new ArrayList<>())); + localContext.put("first", 10); + localContext.put("deferred2", DeferredValue.instance()); + String deferredOutput = expectedTemplateInterpreter.assertExpectedOutput( + "defers-macro-for-do-and-print/test" + ); + Object myList = localContext.get("my_list"); + assertThat(myList).isInstanceOf(DeferredValue.class); + assertThat(((DeferredValue) myList).getOriginalValue()) + .isEqualTo(ImmutableList.of(10L)); + + localContext.put("my_list", ((DeferredValue) myList).getOriginalValue()); + localContext.put("first", 10); + // not deferred anymore + localContext.put("deferred", 5); + localContext.put("deferred2", 10); + + // TODO auto remove deferred + localContext.getDeferredTokens().clear(); + localContext.getGlobalMacro("macro_append").setDeferred(false); + + String output = interpreter.render(deferredOutput); + assertThat(output.replace("\n", "")) + .isEqualTo("Is ([]),Macro: [10]Is ([10]),Is ([10, 5]),Macro: [10, 5, 10]"); + } + + @Test + public void itDefersMacroInFor() { + localContext.put("my_list", new PyList(new ArrayList<>())); + expectedTemplateInterpreter.assertExpectedOutput("defers-macro-in-for/test"); + } + + @Test + public void itDefersMacroInIf() { + localContext.put("my_list", new PyList(new ArrayList<>())); + expectedTemplateInterpreter.assertExpectedOutput("defers-macro-in-if/test"); + } + + @Test + public void itPutsDeferredImportedMacroInOutput() { + expectedTemplateInterpreter.assertExpectedOutput( + "puts-deferred-imported-macro-in-output/test" + ); + } + + @Test + public void itPutsDeferredImportedMacroInOutputSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "puts-deferred-imported-macro-in-output/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "puts-deferred-imported-macro-in-output/test.expected" + ); + } + + @Test + public void itPutsDeferredFromedMacroInOutput() { + expectedTemplateInterpreter.assertExpectedOutput( + "puts-deferred-fromed-macro-in-output/test" + ); + } + + @Test + public void itEagerlyDefersMacro() { + localContext.put("foo", "I am foo"); + localContext.put("bar", "I am bar"); + expectedTemplateInterpreter.assertExpectedOutput("eagerly-defers-macro/test"); + } + + @Test + public void itEagerlyDefersMacroSecondPass() { + localContext.put("foo", "I am foo"); + localContext.put("bar", "I am bar"); + localContext.put("deferred", true); + expectedTemplateInterpreter.assertExpectedOutput( + "eagerly-defers-macro/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "eagerly-defers-macro/test.expected" + ); + } + + @Test + public void itLoadsImportedMacroSyntax() { + expectedTemplateInterpreter.assertExpectedOutput("loads-imported-macro-syntax/test"); + } + + @Test + public void itDefersCaller() { + expectedTemplateInterpreter.assertExpectedOutput("defers-caller/test"); + } + + @Test + public void itDefersCallerSecondPass() { + localContext.put("deferred", "foo"); + expectedTemplateInterpreter.assertExpectedOutput("defers-caller/test.expected"); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-caller/test.expected" + ); + } + + @Test + public void itDefersMacroInExpression() { + expectedTemplateInterpreter.assertExpectedOutput("defers-macro-in-expression/test"); + } + + @Test + public void itDefersMacroInExpressionSecondPass() { + interpreter.resolveELExpression("(range(0,1))", -1); + localContext.put("deferred", 5); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-macro-in-expression/test.expected" + ); + localContext.put("deferred", 5); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-macro-in-expression/test.expected" + ); + } + + @Test + public void itHandlesDeferredInIfchanged() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-in-ifchanged/test" + ); + } + + @Test + public void itDefersIfchanged() { + expectedTemplateInterpreter.assertExpectedOutput("defers-ifchanged/test"); + } + + @Test + public void itHandlesCycleInDeferredFor() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-cycle-in-deferred-for/test" + ); + } + + @Test + public void itHandlesCycleInDeferredForSecondPass() { + localContext.put("deferred", new String[] { "foo", "bar", "foobar", "baz" }); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-cycle-in-deferred-for/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-cycle-in-deferred-for/test.expected" + ); + } + + @Test + public void itHandlesDeferredInCycle() { + expectedTemplateInterpreter.assertExpectedOutput("handles-deferred-in-cycle/test"); + } + + @Test + public void itHandlesDeferredCycleAs() { + expectedTemplateInterpreter.assertExpectedOutput("handles-deferred-cycle-as/test"); + } + + @Test + public void itHandlesDeferredCycleAsSecondPass() { + localContext.put("deferred", "hello"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-cycle-as/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-cycle-as/test.expected" + ); + } + + @Test + public void itHandlesNonDeferringCycles() { + expectedTemplateInterpreter.assertExpectedOutput("handles-non-deferring-cycles/test"); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-non-deferring-cycles/test" + ); + } + + @Test + public void itHandlesAutoEscape() { + localContext.put("myvar", "foo < bar"); + expectedTemplateInterpreter.assertExpectedOutput("handles-auto-escape/test"); + } + + @Test + public void itWrapsCertainOutputInRaw() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(false) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + globalContext, + config + ); + JinjavaInterpreter noNestedInterpreter = new JinjavaInterpreter(parentInterpreter); + + JinjavaInterpreter.pushCurrent(noNestedInterpreter); + try { + ExpectedTemplateInterpreter + .withSensibleCurrentPath(jinjava, noNestedInterpreter, "eager") + .assertExpectedOutput("wraps-certain-output-in-raw/test"); + assertThat(noNestedInterpreter.getErrors()).isEmpty(); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itHandlesDeferredImportVars() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-deferred-import-vars/test" + ); + } + + @Test + public void itHandlesDeferredImportVarsSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-import-vars/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-import-vars/test.expected" + ); + } + + @Test + public void itHandlesNonDeferredImportVars() { + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-non-deferred-import-vars/test" + ); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-non-deferred-import-vars/test" + ); + } + + @Test + public void itHandlesDeferredFromImportAs() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-from-import-as/test" + ); + } + + @Test + public void itHandlesDeferredFromImportAsSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-from-import-as/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-from-import-as/test.expected" + ); + } + + @Test + public void itPreservesValueSetInIf() { + expectedTemplateInterpreter.assertExpectedOutput("preserves-value-set-in-if/test"); + } + + @Test + public void itHandlesCycleWithQuote() { + expectedTemplateInterpreter.assertExpectedOutput("handles-cycle-with-quote/test"); + } + + @Test + public void itHandlesUnknownFunctionErrors() { + JinjavaInterpreter eagerInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + JinjavaInterpreter defaultInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .build() + ); + try { + JinjavaInterpreter.pushCurrent(eagerInterpreter); + ExpectedTemplateInterpreter + .withSensibleCurrentPath(jinjava, eagerInterpreter, "eager") + .assertExpectedOutput("handles-unknown-function-errors/test"); + } finally { + JinjavaInterpreter.popCurrent(); + } + try { + JinjavaInterpreter.pushCurrent(defaultInterpreter); + + ExpectedTemplateInterpreter + .withSensibleCurrentPath(jinjava, defaultInterpreter, "eager") + .assertExpectedOutput("handles-unknown-function-errors/test"); + } finally { + JinjavaInterpreter.popCurrent(); + } + assertThat(eagerInterpreter.getErrors()).hasSize(2); + assertThat(defaultInterpreter.getErrors()).hasSize(2); + } + + @Test + public void itKeepsMaxMacroRecursionDepth() { + expectedTemplateInterpreter.assertExpectedOutput( + "keeps-max-macro-recursion-depth/test" + ); + } + + @Test + public void itHandlesComplexRaw() { + expectedTemplateInterpreter.assertExpectedOutput("handles-complex-raw/test"); + } + + @Test + public void itHandlesDeferredInNamespace() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-in-namespace/test" + ); + } + + @Test + public void itHandlesDeferredInNamespaceSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-in-namespace/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-in-namespace/test.expected" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsAcrossScope() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-across-scope/test" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsAcrossScopeSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-across-scope/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-namespace-for-set-tags-across-scope/test.expected" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsInForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-in-for-loop/test" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsInForLoopSecondPass() { + localContext.put("deferred", 2); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-in-for-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-namespace-for-set-tags-in-for-loop/test.expected" + ); + } + + @Test + public void itHandlesClashingNameInMacro() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-clashing-name-in-macro/test" + ); + } + + @Test + public void itHandlesClashingNameInMacroSecondPass() { + localContext.put("deferred", 0); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-clashing-name-in-macro/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-clashing-name-in-macro/test.expected" + ); + } + + @Test + public void itHandlesBlockSetInDeferredIf() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-block-set-in-deferred-if/test" + ); + } + + @Test + public void itHandlesBlockSetInDeferredIfSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-block-set-in-deferred-if/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-block-set-in-deferred-if/test.expected" + ); + } + + @Test + public void itDoesntOverwriteElif() { + expectedTemplateInterpreter.assertExpectedOutput("doesnt-overwrite-elif/test"); + } + + @Test + public void itHandlesSetAndModifiedInFor() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-set-and-modified-in-for/test" + ); + } + + @Test + public void itHandlesSetInFor() { + expectedTemplateInterpreter.assertExpectedOutput("handles-set-in-for/test"); + } + + @Test + public void itHandlesDeferringLoopVariable() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferring-loop-variable/test" + ); + } + + @Test + public void itDefersChangesWithinDeferredSetBlock() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "defers-changes-within-deferred-set-block/test" + ); + } + + @Test + public void itDefersChangesWithinDeferredSetBlockSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-changes-within-deferred-set-block/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-changes-within-deferred-set-block/test.expected" + ); + } + + @Test + public void itHandlesImportWithMacrosInDeferredIf() { + String template = expectedTemplateInterpreter.getFixtureTemplate( + "handles-import-with-macros-in-deferred-if/test" + ); + JinjavaInterpreter.getCurrent().render(template); + assertThat(JinjavaInterpreter.getCurrent().getContext().getDeferredNodes()) + .isNotEmpty(); + } + + @Test + public void itHandlesImportInDeferredIf() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-import-in-deferred-if/test" + ); + } + + @Test + public void itAllowsMetaContextVarOverriding() { + interpreter.getContext().addMetaContextVariables(Collections.singleton("meta")); + interpreter.getContext().put("meta", "META"); + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "allows-meta-context-var-overriding/test" + ); + } + + @Test + public void itHandlesValueModifiedInMacro() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-value-modified-in-macro/test" + ); + } + + @Test + public void itHandlesDoubleImportModification() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-double-import-modification/test" + ); + } + + @Test + public void itHandlesDoubleImportModificationSecondPass() { + interpreter.getContext().put("deferred", false); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-double-import-modification/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-double-import-modification/test.expected" + ); + } + + @Test + public void itHandlesSameNameImportVar() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-same-name-import-var/test" + ); + } + + @Test + public void itHandlesSameNameImportVarSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-same-name-import-var/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-same-name-import-var/test.expected" + ); + } + + @Test + public void itReconstructsTypesProperly() { + expectedTemplateInterpreter.assertExpectedOutput("reconstructs-types-properly/test"); + } + + @Test + public void itRunsForLoopInsideDeferredForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "runs-for-loop-inside-deferred-for-loop/test" + ); + } + + @Test + public void itModifiesVariableInDeferredMacro() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "modifies-variable-in-deferred-macro/test" + ); + } + + @Test + public void itRevertsSimple() { + expectedTemplateInterpreter.assertExpectedOutput("reverts-simple/test"); + } + + @Test + public void itScopesResettingBindings() { + expectedTemplateInterpreter.assertExpectedOutput("scopes-resetting-bindings/test"); + } + + @Test + public void itReconstructsWithMultipleLoops() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-with-multiple-loops/test" + ); + } + + @Test + public void itFullyDefersFilteredMacro() { + // Macro and set tag reconstruction are flipped so not exactly idempotent, but functionally identical + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "fully-defers-filtered-macro/test" + ); + } + + @Test + public void itFullyDefersFilteredMacroSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "fully-defers-filtered-macro/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "fully-defers-filtered-macro/test.expected" + ); + } + + @Test + public void itDefersLargeLoop() { + expectedTemplateInterpreter.assertExpectedOutput("defers-large-loop/test"); + } + + @Test + public void itHandlesSetInInnerScope() { + expectedTemplateInterpreter.assertExpectedOutput("handles-set-in-inner-scope/test"); + } + + @Test + public void itCorrectlyDefersWithMultipleLoops() { + expectedTemplateInterpreter.assertExpectedOutput( + "correctly-defers-with-multiple-loops/test" + ); + } + + @Test + public void itRevertsModificationWithDeferredLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "reverts-modification-with-deferred-loop/test" + ); + } + + @Test + public void itReconstructsMapNode() { + expectedTemplateInterpreter.assertExpectedOutput("reconstructs-map-node/test"); + } + + @Test + public void itReconstructsMapNodeSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-map-node/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-map-node/test.expected" + ); + } + + @Test + public void itHasProperLineStripping() { + expectedTemplateInterpreter.assertExpectedOutput("has-proper-line-stripping/test"); + } + + @Test + public void itDefersCallTagWithDeferredArgument() { + expectedTemplateInterpreter.assertExpectedOutput( + "defers-call-tag-with-deferred-argument/test" + ); + } + + @Test + public void itDefersCallTagWithDeferredArgumentSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-call-tag-with-deferred-argument/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-call-tag-with-deferred-argument/test.expected" + ); + } + + @Test + public void itHandlesDuplicateVariableReferenceModification() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-duplicate-variable-reference-modification/test" + ); + } + + @Test + public void itHandlesDuplicateVariableReferenceSpeculativeModification() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-duplicate-variable-reference-speculative-modification/test" + ); + } + + @Test + public void itHandlesHigherScopeReferenceModification() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-higher-scope-reference-modification/test" + ); + } + + @Test + public void itHandlesHigherScopeReferenceModificationSecondPass() { + interpreter.getContext().put("deferred", "b"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-higher-scope-reference-modification/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-higher-scope-reference-modification/test.expected" + ); + } + + @Test + public void itHandlesReferenceModificationWhenSourceIsLost() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-reference-modification-when-source-is-lost/test" + ); + } + + @Test + public void itDoesNotReferentialDeferForSetVars() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "does-not-referential-defer-for-set-vars/test" + ); + } + + @Test + public void itKeepsScopeIsolationFromForLoops() { + expectedTemplateInterpreter.assertExpectedOutput( + "keeps-scope-isolation-from-for-loops/test" + ); + } + + @Test + public void itDoesNotOverrideImportModificationInFor() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "does-not-override-import-modification-in-for/test" + ); + } + + @Test + public void itDoesNotOverrideImportModificationInForSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-override-import-modification-in-for/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "does-not-override-import-modification-in-for/test.expected" + ); + } + + @Test + public void itHandlesDeferredForLoopVarFromMacro() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-deferred-for-loop-var-from-macro/test" + ); + } + + @Test + public void itHandlesDeferredForLoopVarFromMacroSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-for-loop-var-from-macro/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-for-loop-var-from-macro/test.expected" + ); + } + + @Test + public void itReconstructsBlockSetVariablesInForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-block-set-variables-in-for-loop/test" + ); + } + + @Test + public void itReconstructsNullVariablesInDeferredCaller() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "reconstructs-null-variables-in-deferred-caller/test" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsUsingPeriod() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-using-period/test" + ); + } + + @Test + public void itReconstructsNamespaceForSetTagsUsingPeriodSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-namespace-for-set-tags-using-period/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-namespace-for-set-tags-using-period/test.expected" + ); + } + + @Test + public void itUsesUniqueMacroNames() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "uses-unique-macro-names/test" + ); + } + + @Test + public void itUsesUniqueMacroNamesSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "uses-unique-macro-names/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "uses-unique-macro-names/test.expected" + ); + } + + @Test + public void itReconstructsWordsFromInsideNestedExpressions() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-words-from-inside-nested-expressions/test" + ); + } + + @Test + public void itReconstructsWordsFromInsideNestedExpressionsSecondPass() { + interpreter.getContext().put("deferred", new PyList(new ArrayList<>())); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-words-from-inside-nested-expressions/test.expected" + ); + } + + @Test + public void itDoesNotReconstructExtraTimes() { + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-reconstruct-extra-times/test" + ); + } + + @Test + public void itAllowsModificationInResolvedForLoop() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "allows-modification-in-resolved-for-loop/test" + ); + } + + @Test + @Ignore // Test isn't necessary after https://github.com/HubSpot/jinjava/pull/988 got reverted + public void itOnlyDefersLoopItemOnCurrentContext() { + expectedTemplateInterpreter.assertExpectedOutput( + "only-defers-loop-item-on-current-context/test" + ); + } + + @Test + public void itRunsMacroFunctionInDeferredExecutionMode() { + expectedTemplateInterpreter.assertExpectedOutput( + "runs-macro-function-in-deferred-execution-mode/test" + ); + } + + @Test + public void itKeepsMacroModificationsInScope() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "keeps-macro-modifications-in-scope/test" + ); + } + + @Test + public void itKeepsMacroModificationsInScopeSecondPass() { + interpreter.getContext().put("deferred", true); + expectedTemplateInterpreter.assertExpectedOutput( + "keeps-macro-modifications-in-scope/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "keeps-macro-modifications-in-scope/test.expected" + ); + } + + @Test + public void itDoesNotReconstructVariableInWrongScope() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "does-not-reconstruct-variable-in-wrong-scope/test" + ); + } + + @Test + public void itDoesNotReconstructVariableInWrongScopeSecondPass() { + interpreter.getContext().put("deferred", true); + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-reconstruct-variable-in-wrong-scope/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "does-not-reconstruct-variable-in-wrong-scope/test.expected" + ); + } + + @Test + public void itReconstructsDeferredVariableEventually() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-deferred-variable-eventually/test" + ); + } + + @Test + public void itDoesntDoubleAppendInDeferredSet() { + expectedTemplateInterpreter.assertExpectedOutput( + "doesnt-double-append-in-deferred-set/test" + ); + } + + @Test + public void itDoesntDoubleAppendInDeferredMacro() { + expectedTemplateInterpreter.assertExpectedOutput( + "doesnt-double-append-in-deferred-macro/test" + ); + } + + @Test + public void itDoesNotReconstructVariableInSetInWrongScope() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "does-not-reconstruct-variable-in-set-in-wrong-scope/test" + ); + } + + @Test + public void itRreconstructsValueUsedInDeferredImportedMacro() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "reconstructs-value-used-in-deferred-imported-macro/test" + ); + } + + @Test + public void itRreconstructsValueUsedInDeferredImportedMacroSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-value-used-in-deferred-imported-macro/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-value-used-in-deferred-imported-macro/test.expected" + ); + } + + @Test + public void itAllowsDeferredLazyReferenceToGetOverridden() { + expectedTemplateInterpreter.assertExpectedOutput( + "allows-deferred-lazy-reference-to-get-overridden/test" + ); + } + + @Test + public void itAllowsDeferredLazyReferenceToGetOverriddenSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "allows-deferred-lazy-reference-to-get-overridden/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "allows-deferred-lazy-reference-to-get-overridden/test.expected" + ); + } + + @Test + public void itCommitsVariablesFromDoTagWhenPartiallyResolved() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "commits-variables-from-do-tag-when-partially-resolved/test" + ); + } + + @Test + public void itCommitsVariablesFromDoTagWhenPartiallyResolvedSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "commits-variables-from-do-tag-when-partially-resolved/test.expected" + ); + expectedTemplateInterpreter.assertExpectedOutput( + "commits-variables-from-do-tag-when-partially-resolved/test.expected" + ); + } + + @Test + public void itFindsDeferredWordsInsideReconstructedString() { + expectedTemplateInterpreter.assertExpectedOutput( + "finds-deferred-words-inside-reconstructed-string/test" + ); + } + + @Test + public void itReconstructsNestedValueInStringRepresentation() { + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-nested-value-in-string-representation/test" + ); + } + + @Test + public void itReconstructsNestedValueInStringRepresentationSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-nested-value-in-string-representation/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-nested-value-in-string-representation/test.expected" + ); + } + + @Test + public void itDefersLoopSettingMetaContextVar() { + interpreter.getContext().addMetaContextVariables(Collections.singleton("content")); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-loop-setting-meta-context-var/test" + ); + } + + @Test + public void itDefersLoopSettingMetaContextVarSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + interpreter.getContext().addMetaContextVariables(Collections.singleton("content")); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-loop-setting-meta-context-var/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-loop-setting-meta-context-var/test.expected" + ); + } + + @Test + public void itAllowsVariableSharingAliasName() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "allows-variable-sharing-alias-name/test" + ); + } + + @Test + public void itFailsOnModificationInAliasedMacro() { + String input = expectedTemplateInterpreter.getFixtureTemplate( + "fails-on-modification-in-aliased-macro/test" + ); + interpreter.render(input); + // We don't support this + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itHandlesDeferredModificationInCaller() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-modification-in-caller/test" + ); + } + + @Test + public void itHandlesDeferredModificationInCallerSecondPass() { + interpreter.getContext().put("deferred", "c"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-modification-in-caller/test.expected" + ); + } + + @Test + public void itPreservesRawInsideDeferredSetBlock() { + expectedTemplateInterpreter.assertExpectedOutput( + "preserves-raw-inside-deferred-set-block/test" + ); + } + + @Test + public void itReconstructsAliasedMacro() { + expectedTemplateInterpreter.assertExpectedOutput("reconstructs-aliased-macro/test"); + } + + @Test + public void itReconstructsAliasedMacroSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-aliased-macro/test.expected" + ); + } + + @Test + public void itReconstructsBlockPathWhenDeferred() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "reconstructs-block-path-when-deferred/test" + ); + } + + @Test + public void itReconstructsBlockPathWhenDeferredSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-block-path-when-deferred/test.expected" + ); + } + + @Test + public void itReconstructsBlockPathWhenDeferredNested() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "reconstructs-block-path-when-deferred-nested/test" + ); + } + + @Test + public void itReconstructsBlockPathWhenDeferredNestedSecondPass() { + interpreter.getContext().put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-block-path-when-deferred-nested/test.expected" + ); + } + + @Test + public void itKeepsMetaContextVariablesThroughImport() { + setupWithExecutionMode( + new EagerExecutionMode() { + @Override + public void prepareContext(Context context) { + super.prepareContext(context); + context.addMetaContextVariables(Collections.singleton("meta")); + } + } + ); + interpreter.getContext().put("meta", new ArrayList<>()); + expectedTemplateInterpreter.assertExpectedOutput( + "keeps-meta-context-variables-through-import/test" + ); + } + + @Test + public void itWrapsMacroThatWouldChangeCurrentPathInChildScope() { + interpreter + .getContext() + .put(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, "starting path"); + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "wraps-macro-that-would-change-current-path-in-child-scope/test" + ); + } + + @Test + public void itHandlesDeferredBreakInForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-break-in-for-loop/test" + ); + } + + @Test + public void itHandlesDeferredBreakInForLoopSecondPass() { + localContext.put("deferred", 1); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-break-in-for-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-break-in-for-loop/test.expected" + ); + } + + @Test + public void itHandlesBreakInDeferredForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-break-in-deferred-for-loop/test" + ); + } + + @Test + public void itHandlesBreakInDeferredForLoopSecondPass() { + localContext.put("deferred", List.of(0, 1, 2, 3, 4, 5)); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-break-in-deferred-for-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-break-in-deferred-for-loop/test.expected" + ); + } + + @Test + public void itHandlesDeferredContinueInForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-continue-in-for-loop/test" + ); + } + + @Test + public void itHandlesDeferredContinueInForLoopSecondPass() { + localContext.put("deferred", 2); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-continue-in-for-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-continue-in-for-loop/test.expected" + ); + } + + @Test + public void itHandlesContinueInDeferredForLoop() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-continue-in-deferred-for-loop/test" + ); + } + + @Test + public void itHandlesContinueInDeferredForLoopSecondPass() { + localContext.put("deferred", List.of(0, 1, 2, 3, 4, 5)); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-continue-in-deferred-for-loop/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-continue-in-deferred-for-loop/test.expected" + ); + } + + @Test + public void itReconstructsFromedMacro() { + expectedTemplateInterpreter.assertExpectedOutput("reconstructs-fromed-macro/test"); + } + + @Test + public void itHandlesModifiedIncludePath() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-modified-include-path/test" + ); + } + + @Test + public void itHandlesModifiedIncludePathSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-modified-include-path/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-modified-include-path/test.expected" + ); + } + + @Test + public void itDoesNotStackOverflowTryingToBuildHashcode() { + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-stack-overflow-trying-to-build-hashcode/test" + ); + } + + @Test + public void itHandlesDeferredValueInRenderFilter() { + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-value-in-render-filter/test" + ); + } + + @Test + public void itHandlesDeferredUsedInMultipleBlockLevels() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "handles-deferred-used-in-multiple-block-levels/test" + ); + } + + @Test + public void itHandlesDeferredUsedInMultipleBlockLevelsSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "handles-deferred-used-in-multiple-block-levels/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "handles-deferred-used-in-multiple-block-levels/test.expected" + ); + } + + @Test + public void itDoesNotDeferBlockWhenOnlyMiddleDefers() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "does-not-defer-block-when-only-middle-defers/test" + ); + } + + @Test + public void itDoesNotDeferBlockWhenOnlyMiddleDefersSecondPass() { + localContext.put("deferred", "resolved"); + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-defer-block-when-only-middle-defers/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "does-not-defer-block-when-only-middle-defers/test.expected" + ); + } + + @Test + public void itPreservesBlocksForReconstructionOrder() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "preserves-blocks-for-reconstruction-order/test" + ); + } + + @Test + public void itPreservesBlocksForReconstructionOrderSecondPhase() { + localContext.put("deferred", "resolved"); + String twoPhaseOutput = expectedTemplateInterpreter.assertExpectedOutput( + "preserves-blocks-for-reconstruction-order/test.expected" + ); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "preserves-blocks-for-reconstruction-order/test.expected" + ); + // Sanity check + assertThat(twoPhaseOutput) + .isEqualToIgnoringWhitespace( + expectedTemplateInterpreter.renderTemplate( + "preserves-blocks-for-reconstruction-order/test" + ) + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/ExpectedNodeInterpreter.java b/src/test/java/com/hubspot/jinjava/ExpectedNodeInterpreter.java new file mode 100644 index 000000000..ca438fed1 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/ExpectedNodeInterpreter.java @@ -0,0 +1,65 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.TreeParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class ExpectedNodeInterpreter { + + private JinjavaInterpreter interpreter; + private Tag tag; + private String path; + + public ExpectedNodeInterpreter(JinjavaInterpreter interpreter, Tag tag, String path) { + this.interpreter = interpreter; + this.tag = tag; + this.path = path; + } + + public String assertExpectedOutput(String name) { + TagNode tagNode = (TagNode) fixture(name); + String output = tag.interpret(tagNode, interpreter); + assertThat(ExpectedTemplateInterpreter.prettify(output.trim())) + .isEqualTo(ExpectedTemplateInterpreter.prettify(expected(name).trim())); + return output; + } + + public Node fixture(String name) { + try { + return new TreeParser( + interpreter, + ExpectedTemplateInterpreter.simplify( + Resources.toString( + Resources.getResource(String.format("%s/%s.jinja", path, name)), + StandardCharsets.UTF_8 + ) + ) + ) + .buildTree() + .getChildren() + .getFirst(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public String expected(String name) { + try { + return ExpectedTemplateInterpreter.simplify( + Resources.toString( + Resources.getResource(String.format("%s/%s.expected.jinja", path, name)), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/ExpectedTemplateInterpreter.java b/src/test/java/com/hubspot/jinjava/ExpectedTemplateInterpreter.java new file mode 100644 index 000000000..8042bfc2b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/ExpectedTemplateInterpreter.java @@ -0,0 +1,199 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.mode.DefaultExecutionMode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class ExpectedTemplateInterpreter { + + private Jinjava jinjava; + private JinjavaInterpreter interpreter; + private String path; + private boolean sensibleCurrentPath = false; + + public ExpectedTemplateInterpreter( + Jinjava jinjava, + JinjavaInterpreter interpreter, + String path + ) { + this.jinjava = jinjava; + this.interpreter = interpreter; + this.path = path; + } + + public static ExpectedTemplateInterpreter withSensibleCurrentPath( + Jinjava jinjava, + JinjavaInterpreter interpreter, + String path + ) { + return new ExpectedTemplateInterpreter(jinjava, interpreter, path, true); + } + + private ExpectedTemplateInterpreter( + Jinjava jinjava, + JinjavaInterpreter interpreter, + String path, + boolean sensibleCurrentPath + ) { + this.jinjava = jinjava; + this.interpreter = interpreter; + this.path = path; + this.sensibleCurrentPath = sensibleCurrentPath; + } + + public String assertExpectedOutput(String name) { + String output = renderTemplate(name); + assertThat(JinjavaInterpreter.getCurrent().getContext().getDeferredNodes()) + .as("Ensure no deferred nodes were created") + .isEmpty(); + assertThat(prettify(output.trim())).isEqualTo(prettify(expected(name).trim())); + assertThat(prettify(JinjavaInterpreter.getCurrent().render(output).trim())) + .isEqualTo(prettify(expected(name).trim())); + return output; + } + + public String renderTemplate(String name) { + String template = getFixtureTemplate(name); + return JinjavaInterpreter.getCurrent().render(template); + } + + public String assertExpectedOutputNonIdempotent(String name) { + String output = renderTemplate(name); + assertThat(JinjavaInterpreter.getCurrent().getContext().getDeferredNodes()) + .as("Ensure no deferred nodes were created") + .isEmpty(); + assertThat(prettify(output.trim())).isEqualTo(prettify(expected(name).trim())); + return output; + } + + public String assertExpectedNonEagerOutput(String name) { + String output; + try { + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(20) + .withEnableRecursiveMacroCalls(true) + .build() + ); + JinjavaInterpreter.pushCurrent(preserveInterpreter); + + try (InterpreterScopeClosable ignored = preserveInterpreter.enterScope()) { + preserveInterpreter.getContext().putAll(interpreter.getContext()); + String template = getFixtureTemplate(name); + output = JinjavaInterpreter.getCurrent().render(template); + assertThat(JinjavaInterpreter.getCurrent().getContext().getDeferredNodes()) + .as("Ensure no deferred nodes were created") + .isEmpty(); + assertThat(output.trim()).isEqualTo(expected(name).trim()); + } + } finally { + JinjavaInterpreter.popCurrent(); + } + if (name.contains(".expected")) { + String originalName = name.replace(".expected", ""); + try { + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(20) + .withEnableRecursiveMacroCalls(true) + .build() + ); + JinjavaInterpreter.pushCurrent(preserveInterpreter); + + preserveInterpreter.getContext().putAll(interpreter.getContext()); + String template = getFixtureTemplate(originalName); + try (InterpreterScopeClosable ignored = preserveInterpreter.enterScope()) { + output = JinjavaInterpreter.getCurrent().render(template); + assertThat(JinjavaInterpreter.getCurrent().getContext().getDeferredNodes()) + .as("Ensure no deferred nodes were created") + .isEmpty(); + assertThat(prettify(output.trim())).isEqualTo(prettify(expected(name).trim())); + } + } finally { + JinjavaInterpreter.popCurrent(); + } + } + return output; + } + + static String prettify(String string) { + return string.replaceAll("([}%]})([^\\s])", "$1\\\\\n$2"); + } + + public String getFixtureTemplate(String name) { + try { + if (sensibleCurrentPath) { + JinjavaInterpreter + .getCurrent() + .getContext() + .getCurrentPathStack() + .push(String.format("%s/%s.jinja", path, name), 0, 0); + interpreter + .getContext() + .put( + RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, + String.format("%s/%s.jinja", path, name) + ); + } + return simplify( + Resources.toString( + Resources.getResource(String.format("%s/%s.jinja", path, name)), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String expected(String name) { + try { + return simplify( + Resources.toString( + Resources.getResource(String.format("%s/%s.expected.jinja", path, name)), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static String simplify(String prettified) { + return prettified.replaceAll("\\\\\n\\s*", ""); + } + + public String getDeferredFixtureTemplate(String templateLocation) { + try { + return Resources.toString( + Resources.getResource("deferred/" + templateLocation), + Charsets.UTF_8 + ); + } catch (IOException e) { + return null; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/FeaturesTest.java b/src/test/java/com/hubspot/jinjava/FeaturesTest.java new file mode 100644 index 000000000..eaaa63269 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/FeaturesTest.java @@ -0,0 +1,87 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; +import com.hubspot.jinjava.features.Features; +import com.hubspot.jinjava.interpret.Context; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import org.junit.Before; +import org.junit.Test; + +public class FeaturesTest { + + private static final String ALWAYS_OFF = "alwaysOff"; + private static final String ALWAYS_ON = "alwaysOn"; + private static final String DATE_PAST = "datePast"; + private static final String DATE_FUTURE = "dateFuture"; + private static final String DELEGATING = "delegating"; + + private Features features; + + private boolean delegateActive = false; + + private Context context = new Context(); + + @Before + public void setUp() throws Exception { + features = + new Features( + FeatureConfig + .newBuilder() + .add(ALWAYS_OFF, FeatureStrategies.INACTIVE) + .add(ALWAYS_ON, FeatureStrategies.ACTIVE) + .add( + DATE_PAST, + DateTimeFeatureActivationStrategy.of( + ZonedDateTime.of(LocalDateTime.MIN, ZoneId.systemDefault()) + ) + ) + .add( + DATE_FUTURE, + DateTimeFeatureActivationStrategy.of( + ZonedDateTime.of(LocalDateTime.MAX, ZoneId.systemDefault()) + ) + ) + .add(DELEGATING, () -> delegateActive) + .build() + ); + } + + @Test + public void itHasEnabledFeature() { + assertThat(features.isActive(ALWAYS_ON, context)).isTrue(); + } + + @Test + public void itDoesNotHaveDisabledFeature() { + assertThat(features.isActive(ALWAYS_OFF, context)).isFalse(); + } + + @Test + public void itHasPastEnabledFeature() { + assertThat(features.isActive(DATE_PAST, context)).isTrue(); + } + + @Test + public void itDoesNotHaveFutureEnabledFeature() { + assertThat(features.isActive(DATE_FUTURE, context)).isFalse(); + } + + @Test + public void itUsesDelegate() { + delegateActive = false; + assertThat(features.isActive(DELEGATING, context)).isEqualTo(delegateActive); + delegateActive = true; + assertThat(features.isActive(DELEGATING, context)).isEqualTo(delegateActive); + } + + @Test + public void itDefaultsToFalse() { + assertThat(features.isActive("unknown", context)).isFalse(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/FilterOverrideTest.java b/src/test/java/com/hubspot/jinjava/FilterOverrideTest.java new file mode 100644 index 000000000..f77ccbf70 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/FilterOverrideTest.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.testobjects.FilterOverrideTestObjects; +import java.util.HashMap; +import org.junit.Test; + +public class FilterOverrideTest { + + @Test + public void itAllowsUsersToOverrideBuiltInFilters() { + Jinjava jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + String template = "{{ 5 | add(6) }}"; + + assertThat(jinjava.render(template, new HashMap<>())).isEqualTo("11"); + + jinjava + .getGlobalContext() + .registerClasses(FilterOverrideTestObjects.DescriptiveAddFilter.class); + assertThat(jinjava.render(template, new HashMap<>())).isEqualTo("5 + 6 = 11"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/FullSnippetsTest.java b/src/test/java/com/hubspot/jinjava/FullSnippetsTest.java new file mode 100644 index 000000000..6dead8a02 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/FullSnippetsTest.java @@ -0,0 +1,104 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class FullSnippetsTest { + + private JinjavaInterpreter interpreter; + private Jinjava jinjava; + private ExpectedTemplateInterpreter expectedTemplateInterpreter; + Context globalContext = new Context(); + Context localContext; // ref to context created with global as parent + + @Before + public void setup() { + JinjavaInterpreter.popCurrent(); + jinjava = new Jinjava(); + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(20) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + globalContext, + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + expectedTemplateInterpreter = + new ExpectedTemplateInterpreter(jinjava, interpreter, "snippets"); + localContext = interpreter.getContext(); + + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + try { + assertThat(interpreter.getErrors()).isEmpty(); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itDoesNotOverrideCallTagFromHigherScope() { + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-override-call-tag-from-higher-scope" + ); + } + + @Test + public void itDoesNotOverrideMacroFunctionsFromHigherScope() { + expectedTemplateInterpreter.assertExpectedOutput( + "does-not-override-macro-functions-from-higher-scope" + ); + } + + @Test + public void itUsesLowerScopeValueInMacroEvaluation() { + expectedTemplateInterpreter.assertExpectedOutput( + "uses-lower-scope-value-in-macro-evaluation" + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/NonRevertingEagerTest.java b/src/test/java/com/hubspot/jinjava/NonRevertingEagerTest.java new file mode 100644 index 000000000..d724fa72f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/NonRevertingEagerTest.java @@ -0,0 +1,29 @@ +package com.hubspot.jinjava; + +import com.hubspot.jinjava.mode.NonRevertingEagerExecutionMode; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class NonRevertingEagerTest extends EagerTest { + + @Override + @Before + public void setup() { + setupWithExecutionMode(NonRevertingEagerExecutionMode.instance()); + } + + @Ignore + @Override + @Test + public void itCorrectlyDefersWithMultipleLoops() { + super.itCorrectlyDefersWithMultipleLoops(); + } + + @Ignore + @Override + @Test + public void itRevertsModificationWithDeferredLoop() { + super.itRevertsModificationWithDeferredLoop(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java b/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java new file mode 100644 index 000000000..9d43062d5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java @@ -0,0 +1,73 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import java.util.HashMap; +import org.junit.Test; + +public class TrailingNewlineTest { + + private static final String TEMPLATE_WITH_TRAILING_NEWLINE = "hello\n"; + private static final String TEMPLATE_WITHOUT_TRAILING_NEWLINE = "hello"; + private static final String TEMPLATE_MULTIPLE_TRAILING_NEWLINES = "hello\n\n"; + + // ── keepTrailingNewline=true (legacy default: preserve \n) ───────────────── + + @Test + public void itKeepsTrailingNewlineIsTrue() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(true).build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineDefault() { + // Defaults keepTrailingNewline=false (matching Python behaviour) + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + // ── keepTrailingNewline=false (Python-compatible: strip trailing \n) ──────── + + @Test + public void itStripsTrailingNewlineIsFalse() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(false).build() + ); + + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + // ── Edge cases ────────────────────────────────────────────────────────────── + + @Test + public void itDoesNotAffectOutputWithNoTrailingNewline() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(true).build() + ); + + assertThat(jinjava.render(TEMPLATE_WITHOUT_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + @Test + public void itStripsOnlyOneTrailingNewlineNotMultiple() { + // Python only strips a single trailing newline, not all of them. + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render(TEMPLATE_MULTIPLE_TRAILING_NEWLINES, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineFromRenderedExpressions() { + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render("{{ greeting }}\n", ImmutableMap.of("greeting", "hello"))) + .isEqualTo("hello"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverPerformanceTest.java b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverPerformanceTest.java new file mode 100644 index 000000000..1e370331f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverPerformanceTest.java @@ -0,0 +1,139 @@ +package com.hubspot.jinjava.el; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +public class ExpressionResolverPerformanceTest { + + public static void main(String[] args) { + PerformanceTester tester = new PerformanceTester(); + tester.run(); + } + + public static class PerformanceTester { + + private JinjavaInterpreter interpreter; + private Context context; + private long startTime; + + public PerformanceTester() { + interpreter = new Jinjava().newInterpreter(); + context = interpreter.getContext(); + context.put("customMap", new CustomMap()); + context.put("customObject", new CustomObject()); + } + + public void run() { + int iterations = 1000000; + + testMapResolver(iterations); + testMethodResolver(iterations); + } + + public void startTimer() { + startTime = System.currentTimeMillis(); + } + + public void stopTimer() { + System.out.printf("%d msec%n", System.currentTimeMillis() - startTime); + } + + public void testMapResolver(int iterations) { + System.out.println("map resolver with " + iterations + " iterations"); + startTimer(); + + for (int i = 0; i < iterations; i++) { + Object val = interpreter.resolveELExpression("customMap.get(\"thing\")", -1); + assertThat(val).isEqualTo("hey"); + } + + stopTimer(); + } + + public void testMethodResolver(int iterations) { + System.out.println("method resolver with " + iterations + " iterations"); + startTimer(); + + for (int i = 0; i < iterations; i++) { + Object val = interpreter.resolveELExpression("customObject.getThing()", -1); + assertThat(val).isEqualTo("hey"); + } + + stopTimer(); + } + } + + static class CustomObject { + + public CustomObject() {} + + public String getThing() { + return "hey"; + } + } + + static class CustomMap implements Map { + + @Override + public String get(Object key) { + return "hey"; + } + + @Override + public int size() { + return 0; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean containsKey(Object key) { + return false; + } + + @Override + public boolean containsValue(Object value) { + return false; + } + + @Override + public String put(String key, String value) { + return null; + } + + @Override + public String remove(Object key) { + return null; + } + + @Override + public void putAll(Map m) {} + + @Override + public void clear() {} + + @Override + public Set keySet() { + return null; + } + + @Override + public Collection values() { + return null; + } + + @Override + public Set> entrySet() { + return null; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java index c87eeb9eb..83347281a 100644 --- a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java @@ -1,108 +1,204 @@ package com.hubspot.jinjava.el; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; -import java.util.Date; -import java.util.List; -import java.util.Map; - -import org.junit.Before; -import org.junit.Test; - -import com.google.common.collect.ForwardingList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.Context.Library; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.LazyExpression; +import com.hubspot.jinjava.interpret.RenderResult; import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; -import com.hubspot.jinjava.objects.PyWrapper; import com.hubspot.jinjava.objects.date.PyishDate; +import com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects; +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; @SuppressWarnings("unchecked") public class ExpressionResolverTest { private JinjavaInterpreter interpreter; private Context context; + private Jinjava jinjava; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + interpreter = jinjava.newInterpreter(); context = interpreter.getContext(); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); } @Test - public void itResolvesListLiterals() throws Exception { + public void itResolvesListLiterals() { Object val = interpreter.resolveELExpression("['0.5','50']", -1); List list = (List) val; assertThat(list).containsExactly("0.5", "50"); } @Test - public void itResolvesImmutableListLiterals() throws Exception { + public void itResolvesImmutableListLiterals() { Object val = interpreter.resolveELExpression("('0.5','50')", -1); List list = (List) val; assertThat(list).containsExactly("0.5", "50"); } @Test(expected = UnsupportedOperationException.class) - public void testTuplesAreImmutable() throws Exception { + public void testTuplesAreImmutable() { Object val = interpreter.resolveELExpression("('0.5','50')", -1); List list = (List) val; list.add("foo"); } @Test - public void itCanCompareStrings() throws Exception { + public void itCanCompareStrings() { context.put("foo", "white"); - assertThat(interpreter.resolveELExpression("'2013-12-08 16:00:00+00:00' > '2013-12-08 13:00:00+00:00'", -1)).isEqualTo(Boolean.TRUE); - assertThat(interpreter.resolveELExpression("foo == \"white\"", -1)).isEqualTo(Boolean.TRUE); + assertThat( + interpreter.resolveELExpression( + "'2013-12-08 16:00:00+00:00' > '2013-12-08 13:00:00+00:00'", + -1 + ) + ) + .isEqualTo(Boolean.TRUE); + assertThat(interpreter.resolveELExpression("foo == \"white\"", -1)) + .isEqualTo(Boolean.TRUE); } @Test - public void itResolvesUntrimmedExprs() throws Exception { + public void itResolvesUntrimmedExprs() { context.put("foo", "bar"); Object val = interpreter.resolveELExpression(" foo ", -1); assertThat(val).isEqualTo("bar"); + assertThat(interpreter.getContext().wasExpressionResolved("foo")).isTrue(); } @Test - public void itResolvesMathVals() throws Exception { + public void itResolvesMathVals() { context.put("i_am_seven", 7L); Object val = interpreter.resolveELExpression("(i_am_seven * 2 + 1)/3", -1); assertThat(val).isEqualTo(5.0); + assertThat(interpreter.getContext().wasValueResolved("i_am_seven")).isTrue(); } @Test - public void itResolvesListVal() throws Exception { + public void itResolvesListVal() { context.put("thelist", Lists.newArrayList(1L, 2L, 3L)); Object val = interpreter.resolveELExpression("thelist[1]", -1); assertThat(val).isEqualTo(2L); } @Test - public void itResolvesDictValWithBracket() throws Exception { + public void itResolvesListStringNegative() { + context.put("thelist", Lists.newArrayList("foo", "bar", "blah")); + Object val = interpreter.resolveELExpression("thelist[-1]", -1); + assertThat(val).isEqualTo("blah"); + } + + @Test + public void itResolvesListStringNextToLast() { + context.put("thelist", Lists.newArrayList("foo", "bar", "blah")); + Object val = interpreter.resolveELExpression("thelist[-2]", -1); + assertThat(val).isEqualTo("bar"); + } + + @Test + public void itResolvesListStringNegativeIndicatingFirst() { + context.put("thelist", Lists.newArrayList("foo", "bar", "blah")); + Object val = interpreter.resolveELExpression("thelist[-3]", -1); + assertThat(val).isEqualTo("foo"); + } + + @Test + public void itResolvesListStringNegativeZero() { + context.put("thelist", Lists.newArrayList("foo", "bar", "blah")); + Object val = interpreter.resolveELExpression("thelist[-0]", -1); + assertThat(val).isEqualTo("foo"); + } + + @Test + public void itResolvesListStringNegativeOutOfBounds() { + context.put("thelist", Lists.newArrayList("foo", "bar", "blah")); + Object val = interpreter.resolveELExpression("thelist[-4]", -1); + assertThat(val).isEqualTo(null); + } + + @Test + public void itResolvesDictValWithBracket() { Map dict = Maps.newHashMap(); dict.put("foo", "bar"); context.put("thedict", dict); Object val = interpreter.resolveELExpression("thedict['foo']", -1); assertThat(val).isEqualTo("bar"); + assertThat(interpreter.getContext().wasExpressionResolved("thedict['foo']")).isTrue(); } @Test - public void itResolvesDictValWithDotParam() throws Exception { + public void itResolvesDictValWithDotParam() { Map dict = Maps.newHashMap(); dict.put("foo", "bar"); context.put("thedict", dict); Object val = interpreter.resolveELExpression("thedict.foo", -1); assertThat(val).isEqualTo("bar"); + assertThat(interpreter.getContext().wasExpressionResolved("thedict.foo")).isTrue(); } @Test - public void itResolvesInnerDictVal() throws Exception { + public void itResolvesMapValOnCustomObject() { + ExpressionResolverTestObjects.MyCustomMap dict = + new ExpressionResolverTestObjects.MyCustomMap(); + context.put("thedict", dict); + + Object val = interpreter.resolveELExpression("thedict['foo']", -1); + assertThat(val).isEqualTo("bar"); + assertThat(interpreter.getContext().wasExpressionResolved("thedict['foo']")).isTrue(); + + Object val2 = interpreter.resolveELExpression("thedict.two", -1); + assertThat(val2).isEqualTo("2"); + assertThat(interpreter.getContext().wasExpressionResolved("thedict.two")).isTrue(); + } + + @Test + public void itResolvesOtherMethodsOnCustomMapObject() { + ExpressionResolverTestObjects.MyCustomMap dict = + new ExpressionResolverTestObjects.MyCustomMap(); + context.put("thedict", dict); + + Object val = interpreter.resolveELExpression("thedict.size", -1); + assertThat(val).isEqualTo("777"); + + Object val1 = interpreter.resolveELExpression("thedict.size()", -1); + assertThat(val1).isEqualTo(3); + + Object val2 = interpreter.resolveELExpression("thedict.items()", -1); + assertThat(val2.toString()).isEqualTo("[foo=bar, two=2, size=777]"); + } + + @Test + public void itResolvesInnerDictVal() { Map dict = Maps.newHashMap(); Map inner = Maps.newHashMap(); inner.put("test", "val"); @@ -114,7 +210,7 @@ public void itResolvesInnerDictVal() throws Exception { } @Test - public void itResolvesInnerListVal() throws Exception { + public void itResolvesInnerListVal() { Map dict = Maps.newHashMap(); List inner = Lists.newArrayList("val"); dict.put("inner", inner); @@ -124,26 +220,18 @@ public void itResolvesInnerListVal() throws Exception { assertThat(val).isEqualTo("val"); } - public static class MyCustomList extends ForwardingListimplements PyWrapper { - private final List list; - - public MyCustomList(List list) { - this.list = list; - } - - @Override - protected List delegate() { - return list; - } - - public int getTotalCount() { - return list.size(); - } + @Test + public void itRecordsFilterNames() { + Object val = interpreter.resolveELExpression("2.3 | round", -1); + assertThat(val).isEqualTo(new BigDecimal(2)); + assertThat(interpreter.getContext().wasValueResolved("filter:round")).isTrue(); } @Test - public void callCustomListProperty() throws Exception { - List myList = new MyCustomList<>(Lists.newArrayList(1, 2, 3, 4)); + public void callCustomListProperty() { + List myList = new ExpressionResolverTestObjects.MyCustomList<>( + Lists.newArrayList(1, 2, 3, 4) + ); context.put("mylist", myList); Object val = interpreter.resolveELExpression("mylist.total_count", -1); @@ -151,26 +239,31 @@ public void callCustomListProperty() throws Exception { } @Test - public void complexInWithOrCondition() throws Exception { + public void complexInWithOrCondition() { context.put("foo", "this is
something"); context.put("bar", "this is
something"); - assertThat(interpreter.resolveELExpression("\"
\" in foo or \"
\" in foo", -1)).isEqualTo(true); - assertThat(interpreter.resolveELExpression("\"
\" in bar or \"
\" in bar", -1)).isEqualTo(true); - assertThat(interpreter.resolveELExpression("\"\" in foo or \"\" in foo", -1)).isEqualTo(false); + assertThat(interpreter.resolveELExpression("\"
\" in foo or \"
\" in foo", -1)) + .isEqualTo(true); + assertThat(interpreter.resolveELExpression("\"
\" in bar or \"
\" in bar", -1)) + .isEqualTo(true); + assertThat( + interpreter.resolveELExpression("\"\" in foo or \"\" in foo", -1) + ) + .isEqualTo(false); } @Test - public void unknownProperty() throws Exception { + public void unknownProperty() { interpreter.resolveELExpression("foo", 23); - assertThat(interpreter.getErrors()).isEmpty(); + assertThat(interpreter.getErrorsCopy()).isEmpty(); - context.put("foo", new Object()); + context.put("foo", ""); interpreter.resolveELExpression("foo.bar", 23); - assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrorsCopy()).hasSize(1); - TemplateError e = interpreter.getErrors().get(0); + TemplateError e = interpreter.getErrorsCopy().get(0); assertThat(e.getReason()).isEqualTo(ErrorReason.UNKNOWN); assertThat(e.getLineno()).isEqualTo(23); assertThat(e.getFieldName()).isEqualTo("bar"); @@ -178,33 +271,353 @@ public void unknownProperty() throws Exception { } @Test - public void syntaxError() throws Exception { + public void syntaxError() { interpreter.resolveELExpression("(*&W", 123); - assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrorsCopy()).hasSize(1); - TemplateError e = interpreter.getErrors().get(0); + TemplateError e = interpreter.getErrorsCopy().get(0); assertThat(e.getReason()).isEqualTo(ErrorReason.SYNTAX_ERROR); assertThat(e.getLineno()).isEqualTo(123); assertThat(e.getMessage()).contains("invalid character"); } @Test - public void itWrapsDates() throws Exception { - context.put("myobj", new MyClass(new Date(0))); + public void itWrapsDates() { + context.put("myobj", new ExpressionResolverTestObjects.MyClass(new Date(0))); Object result = interpreter.resolveELExpression("myobj.date", -1); assertThat(result).isInstanceOf(PyishDate.class); assertThat(result.toString()).isEqualTo("1970-01-01 00:00:00"); } - public static final class MyClass { - private Date date; + @Test + public void blackListedProperties() { + context.put("myobj", new ExpressionResolverTestObjects.MyClass(new Date(0))); + interpreter.resolveELExpression("myobj.class.methods[0]", -1); + + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getReason()).isEqualTo(ErrorReason.UNKNOWN); + assertThat(e.getFieldName()).isEqualTo("class"); + assertThat(e.getMessage()).contains("Cannot resolve property 'class'"); + } + + @Test + public void itWillNotReturnClassObjectProperties() { + context.put("myobj", new ExpressionResolverTestObjects.MyClass(new Date(0))); + Object clazz = interpreter.resolveELExpression("myobj.clazz", -1); + assertThat(clazz).isNull(); + } + + @Test + public void blackListedMethods() { + context.put("myobj", new ExpressionResolverTestObjects.MyClass(new Date(0))); + interpreter.resolveELExpression("myobj.wait()", -1); + + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getMessage()) + .contains( + "Cannot find method wait with 0 parameters in class com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects$MyClass" + ); + } + + @Test + public void itWillNotReturnClassObjects() { + context.put("myobj", new ExpressionResolverTestObjects.MyClass(new Date(0))); + interpreter.resolveELExpression("myobj.getClass()", -1); + + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getMessage()) + .contains( + "Cannot find method getClass with 0 parameters in class com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects$MyClass" + ); + } - MyClass(Date date) { - this.date = date; + @Test + public void itBlocksDisabledTags() { + Map> disabled = ImmutableMap.of( + Context.Library.TAG, + ImmutableSet.of("raw") + ); + assertThat(interpreter.render("{% raw %}foo{% endraw %}")).isEqualTo("foo"); + + try ( + JinjavaInterpreter.InterpreterScopeClosable c = interpreter.enterScope(disabled) + ) { + interpreter.render("{% raw %} foo {% endraw %}"); } - public Date getDate() { - return date; + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getItem()).isEqualTo(ErrorItem.TAG); + assertThat(e.getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(e.getMessage()).contains("'raw' is disabled in this context"); + } + + @Test + public void itBlocksDisabledTagsInIncludes() { + final String jinja = "top {% include \"tags/includetag/raw.html\" %}"; + + Map> disabled = ImmutableMap.of( + Context.Library.TAG, + ImmutableSet.of("raw") + ); + assertThat(interpreter.render(jinja)).isEqualTo("top before raw after\n"); + + try ( + JinjavaInterpreter.InterpreterScopeClosable c = interpreter.enterScope(disabled) + ) { + interpreter.render(jinja); } + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getItem()).isEqualTo(ErrorItem.TAG); + assertThat(e.getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(e.getMessage()).contains("'raw' is disabled in this context"); + } + + @Test + public void itBlocksDisabledFilters() { + Map> disabled = ImmutableMap.of( + Context.Library.FILTER, + ImmutableSet.of("truncate") + ); + assertThat(interpreter.resolveELExpression("\"hey\"|truncate(2)", -1)) + .isEqualTo("h..."); + + try ( + JinjavaInterpreter.InterpreterScopeClosable c = interpreter.enterScope(disabled) + ) { + interpreter.resolveELExpression("\"hey\"|truncate(2)", -1); + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getItem()).isEqualTo(ErrorItem.FILTER); + assertThat(e.getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(e.getMessage()).contains("truncate' is disabled in this context"); + } + } + + @Test + public void itBlocksDisabledFunctions() { + Map> disabled = ImmutableMap.of( + Library.FUNCTION, + ImmutableSet.of(":range") + ); + + String template = "hi {% for i in range(1, 3) %}{{i}} {% endfor %}"; + JinjavaInterpreter.popCurrent(); + + String rendered = jinjava.render(template, context); + assertEquals("hi 1 2 ", rendered); + + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withDisabled(disabled) + .build(); + + final RenderResult renderResult = jinjava.renderForResult(template, context, config); + assertEquals("hi ", renderResult.getOutput()); + TemplateError e = renderResult.getErrors().get(0); + assertThat(e.getItem()).isEqualTo(ErrorItem.FUNCTION); + assertThat(e.getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(e.getMessage()).contains("':range' is disabled in this context"); + } + + @Test + public void itBlocksDisabledExpTests() { + Map> disabled = ImmutableMap.of( + Context.Library.EXP_TEST, + ImmutableSet.of("even") + ); + assertThat(interpreter.render("{% if 2 is even %}yes{% endif %}")).isEqualTo("yes"); + + try ( + JinjavaInterpreter.InterpreterScopeClosable c = interpreter.enterScope(disabled) + ) { + interpreter.render("{% if 2 is even %}yes{% endif %}"); + TemplateError e = interpreter.getErrorsCopy().get(0); + assertThat(e.getItem()).isEqualTo(ErrorItem.EXPRESSION_TEST); + assertThat(e.getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(e.getMessage()).contains("even' is disabled in this context"); + } + } + + @Test + public void itStoresResolvedFunctions() { + context.put("datetime", 12345); + final JinjavaConfig config = BaseJinjavaTest.newConfigBuilder().build(); + String template = + "{% for i in range(1, 5) %}{{i}} {% endfor %}\n{{ unixtimestamp(datetime) }}"; + final RenderResult renderResult = jinjava.renderForResult(template, context, config); + assertThat(renderResult.getOutput()).isEqualTo("1 2 3 4 \n12345"); + assertThat(renderResult.getContext().getResolvedFunctions()) + .hasSameElementsAs(ImmutableSet.of(":range", ":unixtimestamp")); + } + + @Test + public void presentOptionalProperty() { + context.put("myobj", new ExpressionResolverTestObjects.OptionalProperty(null, "foo")); + assertThat(interpreter.resolveELExpression("myobj.val", -1)).isEqualTo("foo"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void emptyOptionalProperty() { + context.put("myobj", new ExpressionResolverTestObjects.OptionalProperty(null, null)); + assertThat(interpreter.resolveELExpression("myobj.val", -1)).isNull(); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void presentNestedOptionalProperty() { + context.put( + "myobj", + new ExpressionResolverTestObjects.OptionalProperty( + new ExpressionResolverTestObjects.MyClass(new Date(0)), + "foo" + ) + ); + assertThat(Objects.toString(interpreter.resolveELExpression("myobj.nested.date", -1))) + .isEqualTo("1970-01-01 00:00:00"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void emptyNestedOptionalProperty() { + context.put("myobj", new ExpressionResolverTestObjects.OptionalProperty(null, null)); + assertThat(interpreter.resolveELExpression("myobj.nested.date", -1)).isNull(); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void presentNestedNestedOptionalProperty() { + context.put( + "myobj", + new ExpressionResolverTestObjects.NestedOptionalProperty( + new ExpressionResolverTestObjects.OptionalProperty( + new ExpressionResolverTestObjects.MyClass(new Date(0)), + "foo" + ) + ) + ); + assertThat( + Objects.toString(interpreter.resolveELExpression("myobj.nested.nested.date", -1)) + ) + .isEqualTo("1970-01-01 00:00:00"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itResolvesLazyExpressionsToTheirUnderlyingValue() { + ExpressionResolverTestObjects.TestClass testClass = + new ExpressionResolverTestObjects.TestClass(); + Supplier lazyString = () -> result("hallelujah", testClass); + + context.put("myobj", ImmutableMap.of("test", LazyExpression.of(lazyString, ""))); + + assertThat(Objects.toString(interpreter.resolveELExpression("myobj.test", -1))) + .isEqualTo("hallelujah"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(testClass.isTouched()).isTrue(); + } + + @Test + public void itResolvesNullLazyExpressions() { + Supplier lazyNull = () -> null; + context.put("nullobj", LazyExpression.of(lazyNull, "")); + assertThat(interpreter.resolveELExpression("nullobj", -1)).isNull(); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolvesSuppliersOnlyIfResolved() { + ExpressionResolverTestObjects.TestClass testClass = + new ExpressionResolverTestObjects.TestClass(); + Supplier lazyString = () -> result("hallelujah", testClass); + + context.put( + "myobj", + ImmutableMap.of("test", LazyExpression.of(lazyString, ""), "nope", "test") + ); + + assertThat(Objects.toString(interpreter.resolveELExpression("myobj.nope", -1))) + .isEqualTo("test"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(testClass.isTouched()).isFalse(); + } + + @Test + public void itResolvesLazyExpressionsInNested() { + Supplier lazyObject = + ExpressionResolverTestObjects.TestClass::new; + + context.put("myobj", ImmutableMap.of("test", LazyExpression.of(lazyObject, ""))); + + assertThat(Objects.toString(interpreter.resolveELExpression("myobj.test.name", -1))) + .isEqualTo("Amazing test class"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itResolvesAlternateExpTestSyntax() { + assertThat(interpreter.render("{% if 2 is even %}yes{% endif %}")).isEqualTo("yes"); + + assertThat( + interpreter.render("{% if exptest:even.evaluate(2, null) %}yes{% endif %}") + ) + .isEqualTo("yes"); + assertThat( + interpreter.render("{% if exptest:false.evaluate(false, null) %}yes{% endif %}") + ) + .isEqualTo("yes"); + } + + @Test + public void itResolvesAlternateExpTestSyntaxForTrueAndFalseExpTests() { + assertThat( + interpreter.render("{% if exptest:false.evaluate(false, null) %}yes{% endif %}") + ) + .isEqualTo("yes"); + assertThat( + interpreter.render("{% if exptest:true.evaluate(true, null) %}yes{% endif %}") + ) + .isEqualTo("yes"); + } + + @Test + public void itResolvesAlternateExpTestSyntaxForInExpTests() { + assertThat( + interpreter.render("{% if exptest:in.evaluate(1, null, [1]) %}yes{% endif %}") + ) + .isEqualTo("yes"); + assertThat( + interpreter.render( + "{% if exptest:in.evaluate(2, null, [1]) %}yes{% else %}no{% endif %}" + ) + ) + .isEqualTo("no"); + } + + @Test + public void itAddsErrorRenderingUnclosedExpression() { + interpreter.resolveELExpression("{", 1); + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains( + "Error parsing '{': syntax error at position 4, encountered 'null', expected '}'" + ); + } + + @Test + public void itAddsInvalidInputErrorWhenArithmeticExceptionIsThrown() { + String render = interpreter.render("{% set n = 12/0|round %}{{n}}"); + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains( + "ArithmeticException when resolving expression [[ 12/0|round ]]: ArithmeticException: / by zero" + ); + assertThat(interpreter.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.INVALID_INPUT); + } + + public String result(String value, ExpressionResolverTestObjects.TestClass testClass) { + testClass.touch(); + return value; } } diff --git a/src/test/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilderTest.java b/src/test/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilderTest.java index cc9d404de..ac7e6d85f 100644 --- a/src/test/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilderTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ExtendedSyntaxBuilderTest.java @@ -3,32 +3,40 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.IndexOutOfRangeException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Map; - import org.junit.After; import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @SuppressWarnings("unchecked") public class ExtendedSyntaxBuilderTest { - Context context; - JinjavaInterpreter interpreter; + private static final long MAX_STRING_LENGTH = 10_000; + + private Context context; + private JinjavaInterpreter interpreter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); + interpreter = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(MAX_STRING_LENGTH).build() + ) + .newInterpreter(); JinjavaInterpreter.pushCurrent(interpreter); context = interpreter.getContext(); @@ -67,10 +75,20 @@ public void expTestOp() { assertThat(val("49 is odd")).isEqualTo(true); } + @Test + public void stringExpTestOps() { + assertThat(val("'football' is string_startingwith 'foot'")).isEqualTo(true); + assertThat(val("'football' is string_startingwith 'ball'")).isEqualTo(false); + + assertThat(val("'football' is string_containing 'tb'")).isEqualTo(true); + assertThat(val("'football' is string_containing 'golf'")).isEqualTo(false); + } + @Test public void namedFnArgs() { context.put("path", "/page"); - assertThat(val("path=='/' or truncate(path,length=5,killwords=true,end='')=='/page'")).isEqualTo(true); + assertThat(val("path=='/' or truncate(path,length=5,killwords=true,end='')=='/page'")) + .isEqualTo(true); assertThat(val("truncate('foobar', length = 3)")).isEqualTo("f..."); } @@ -88,6 +106,25 @@ public void stringConcatOperator() { assertThat(val("'foo' ~ 'bar'")).isEqualTo("foobar"); } + @Test + public void itLimitsLengthInStringConcatOperator() { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < MAX_STRING_LENGTH - 1; i++) { + stringBuilder.append("0"); + } + + String longString = stringBuilder.toString(); + + context.put("longString", longString); + assertThat(val("longString ~ ''")).isEqualTo(longString); + assertThat(interpreter.getErrors()).isEmpty(); + + assertThat(val("longString ~ 'OVER'")).isNull(); + + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains(OutputTooBigException.class.getSimpleName()); + } + @Test public void stringInStringOperator() { assertThat(val("'foo' in 'foobar'")).isEqualTo(true); @@ -100,6 +137,19 @@ public void objInCollectionOperator() { assertThat(val("12 in [1, 12, 3]")).isEqualTo(true); } + // TODO: support negated collection membership. See CollectionMembershipOperator + // @Test + // public void stringNotInStringOperator() { + // assertThat(val("'foo' not in 'foobar'")).isEqualTo(false); + // assertThat(val("'gg' not in 'foobar'")).isEqualTo(true); + // } + + // @Test + // public void objNotInCollectionOperator() { + // assertThat(val("12 not in [1, 2, 3]")).isEqualTo(true); + // assertThat(val("12 not in [1, 12, 3]")).isEqualTo(false); + // } + @Test public void conditionalExprWithNoElse() { context.put("foo", "bar"); @@ -114,6 +164,11 @@ public void conditionalExprWithElse() { assertThat(val("'hello' if foo=='barf' else 'hi'")).isEqualTo("hi"); } + @Test + public void conditionalExprWithOrConditionalAndCustomExpression() { + assertThat(val("'a' is equalto 'b' or 'a' is equalto 'a'")).isEqualTo(true); + } + @Test public void newlineEscChar() { context.put("comment", "foo\nbar"); @@ -136,14 +191,40 @@ public void literalTuple() { @Test public void mapLiteral() { - context.put("foo", "bar"); - assertThat((Map) val("{}")).isEmpty(); - Map map = (Map) val("{foo: foo, \"foo2\": foo, foo3: 123, foo4: 'string', foo5: {}, foo6: [1, 2]}"); - assertThat(map).contains(entry("foo", "bar"), entry("foo2", "bar"), entry("foo3", 123L), - entry("foo4", "string"), entry("foo6", Arrays.asList(1L, 2L))); - - assertThat((Map) val("{\"address\":\"123 Main - Boston, MA 02111\"}")) + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_STRING_LENGTH) + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withEvaluateMapKeys(false) + .build() + ) + .build() + ) + .newInterpreter(); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter.getContext().put("foo", "bar"); + assertThat((Map) val("{}")).isEmpty(); + Map map = (Map) val( + "{foo: foo, \"foo2\": foo, foo3: 123, foo4: 'string', foo5: {}, foo6: [1, 2]}" + ); + assertThat(map) + .contains( + entry("foo", "bar"), + entry("foo2", "bar"), + entry("foo3", 123L), + entry("foo4", "string"), + entry("foo6", Arrays.asList(1L, 2L)) + ); + + assertThat( + (Map) val("{\"address\":\"123 Main - Boston, MA 02111\"}") + ) .contains(entry("address", "123 Main - Boston, MA 02111")); + } } @Test @@ -154,7 +235,12 @@ public void complexMapLiteral() { } @Test - public void itParsesDictWithVariableRefs() throws Exception { + public void mapLiteralWithNumericKey() { + assertThat((Map) val("{0:'test'}")).contains(entry("0", "test")); + } + + @Test + public void itParsesDictWithVariableRefs() { List theList = Lists.newArrayList(1L, 2L, 3L); context.put("the_list", theList); context.put("i_am_seven", 7L); @@ -171,7 +257,7 @@ public void itParsesDictWithVariableRefs() throws Exception { } @Test - public void itReturnsLeftResultForOrExpr() throws Exception { + public void itReturnsLeftResultForOrExpr() { context.put("left", "foo"); context.put("right", "bar"); @@ -179,7 +265,7 @@ public void itReturnsLeftResultForOrExpr() throws Exception { } @Test - public void itReturnsRightResultForOrExpr() throws Exception { + public void itReturnsRightResultForOrExpr() { context.put("right", "bar"); assertThat(val("left or right")).isEqualTo("bar"); @@ -188,14 +274,16 @@ public void itReturnsRightResultForOrExpr() throws Exception { private String fixture(String name) { try { return Resources.toString( - Resources.getResource(String.format("el/dict/%s.fixture", name)), StandardCharsets.UTF_8); + Resources.getResource(String.format("el/dict/%s.fixture", name)), + StandardCharsets.UTF_8 + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } @Test - public void testParseExp() throws Exception { + public void testParseExp() { context.put("foo", "fff"); context.put("a", "aaa"); context.put("b", "bbb"); @@ -204,7 +292,7 @@ public void testParseExp() throws Exception { } @Test - public void listRangeSyntax() throws Exception { + public void listRangeSyntax() { List theList = Lists.newArrayList(1, 2, 3, 4, 5); context.put("mylist", theList); assertThat(val("mylist[0:3]")).isEqualTo(Lists.newArrayList(1, 2, 3)); @@ -212,8 +300,150 @@ public void listRangeSyntax() throws Exception { assertThat(val("mylist[2]")).isEqualTo(3); } + @Test + public void outOfRange() { + List emptyList = Lists.newArrayList(); + context.put("emptyList", emptyList); + + // empty case + assertThat(val("emptyList.get(0)")).isNull(); + String errorMessage = "Index %d is out of range for list of size %d"; + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains(String.format(errorMessage, 0, 0)); + + // negative case + assertThat(val("emptyList.get(-1)")).isNull(); + assertThat(interpreter.getErrors().get(1).getMessage()) + .contains(String.format(errorMessage, -1, 0)); + + // out of range for filled array + List theList = Lists.newArrayList(1, 2, 3); + context.put("smallList", theList); + assertThat(val("smallList.get(3)")).isNull(); + assertThat(interpreter.getErrors().get(2).getMessage()) + .contains(String.format(errorMessage, 3, 3)); + + interpreter + .getErrors() + .forEach(e -> + assertThat(e.getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class) + ); + } + + @Test + public void listRangeSyntaxNegativeIndices() { + List theList = Lists.newArrayList(1, 2, 3, 4, 5); + context.put("mylist", theList); + + // assorted valid negative starts and ends + assertThat(val("mylist[0:-1]")).isEqualTo(Lists.newArrayList(1, 2, 3, 4)); + assertThat(val("mylist[0:-2]")).isEqualTo(Lists.newArrayList(1, 2, 3)); + assertThat(val("mylist[-3:-1]")).isEqualTo(Lists.newArrayList(3, 4)); + assertThat(val("mylist[-5:-4]")).isEqualTo(Lists.newArrayList(1)); + + // same start and end -- negative + assertThat(val("mylist[-3:-3]")).isEqualTo(Lists.newArrayList()); + + // negative start, positive end + assertThat(val("mylist[-3:5]")).isEqualTo(Lists.newArrayList(3, 4, 5)); + + // positive start, negative end + assertThat(val("mylist[2:-2]")).isEqualTo(Lists.newArrayList(3)); + + // 6 is out of range, but this is what python does + assertThat(val("mylist[-3:6]")).isEqualTo(Lists.newArrayList(3, 4, 5)); + + // -6 is out of range, but this is what python does + assertThat(val("mylist[-6:3]")).isEqualTo(Lists.newArrayList(1, 2, 3)); + + // start after end + assertThat(val("mylist[-2:-3]")).isEqualTo(Lists.newArrayList()); + } + + @Test + public void arrayWithNegativeIndices() { + String stringToSplit = "one-two-three-four-five"; + context.put("stringToSplit", stringToSplit); + + // Negative index handling on lists happens elsewhere, so make sure we're + // dealing with an array of Strings. + assertThat(val("stringToSplit.split('-')")) + .isEqualTo(new String[] { "one", "two", "three", "four", "five" }); + + assertThat(val("stringToSplit.split('-')[-1]")).isEqualTo("five"); + assertThat(val("stringToSplit.split('-')[1.5]")).isEqualTo(null); + assertThat(val("stringToSplit.split('-')[-1.5]")).isEqualTo(null); + + // out of range returns null, as -6 + the length of the array is still + // negative, and java doesn't support negative array indices. + assertThat(val("stringToSplit.split('-')[-6]")).isEqualTo(null); + + assertThat(val("stringToSplit.split('-')[0:2]")) + .isEqualTo(Lists.newArrayList("one", "two")); + assertThat(val("stringToSplit.split('-')[0:-2]")) + .isEqualTo(Lists.newArrayList("one", "two", "three")); + } + + @Test + public void arrayWithImplicitIndices() { + assertThat(val("[1, 2, 3][1:]")).isEqualTo(Lists.newArrayList(2L, 3L)); + assertThat(val("[1, 2, 3][:2]")).isEqualTo(Lists.newArrayList(1L, 2L)); + } + + @Test + public void invalidNestedAssignmentExpr() { + assertThat(val("content.template_path = 'Custom/Email/Responsive/testing.html'")) + .isEqualTo(null); + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + assertThat(interpreter.getErrorsCopy().get(0).getReason()) + .isEqualTo(ErrorReason.SYNTAX_ERROR); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .containsIgnoringCase("identifier"); + } + + @Test + public void invalidIdentifierAssignmentExpr() { + assertThat(val("content = 'Custom/Email/Responsive/testing.html'")).isEqualTo(null); + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + assertThat(interpreter.getErrorsCopy().get(0).getReason()) + .isEqualTo(ErrorReason.SYNTAX_ERROR); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .containsIgnoringCase("'='"); + } + + @Test + public void invalidPipeOperatorExpr() { + assertThat(val("topics|1")).isEqualTo(null); + assertThat(interpreter.getErrorsCopy()).isNotEmpty(); + assertThat(interpreter.getErrorsCopy().get(0).getReason()) + .isEqualTo(ErrorReason.SYNTAX_ERROR); + } + + @Test + public void itReturnsCorrectSyntaxErrorPositions() { + assertThat( + interpreter.render( + "hi {{ missing thing }}{{ missing thing }}\nI am {{ blah blabbity }} too" + ) + ) + .isEqualTo("hi \nI am too"); + assertThat(interpreter.getErrorsCopy().size()).isEqualTo(3); + assertThat(interpreter.getErrorsCopy().get(0).getLineno()).isEqualTo(1); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()).contains("position 14"); + assertThat(interpreter.getErrorsCopy().get(0).getStartPosition()).isEqualTo(14); + assertThat(interpreter.getErrorsCopy().get(0).getFieldName()).isEqualTo("thing"); + assertThat(interpreter.getErrorsCopy().get(1).getLineno()).isEqualTo(1); + assertThat(interpreter.getErrorsCopy().get(1).getMessage()).contains("position 33"); + assertThat(interpreter.getErrorsCopy().get(1).getStartPosition()).isEqualTo(33); + assertThat(interpreter.getErrorsCopy().get(1).getFieldName()).isEqualTo("thing"); + assertThat(interpreter.getErrorsCopy().get(2).getLineno()).isEqualTo(2); + assertThat(interpreter.getErrorsCopy().get(2).getMessage()).contains("position 13"); + assertThat(interpreter.getErrorsCopy().get(2).getStartPosition()).isEqualTo(13); + assertThat(interpreter.getErrorsCopy().get(2).getFieldName()).isEqualTo("blabbity"); + } + private Object val(String expr) { return interpreter.resolveELExpression(expr, -1); } - } diff --git a/src/test/java/com/hubspot/jinjava/el/TypeConvertingMapELResolverTest.java b/src/test/java/com/hubspot/jinjava/el/TypeConvertingMapELResolverTest.java new file mode 100644 index 000000000..931ea762c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/TypeConvertingMapELResolverTest.java @@ -0,0 +1,61 @@ +package com.hubspot.jinjava.el; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class TypeConvertingMapELResolverTest { + + private TypeConvertingMapELResolver typeConvertingMapELResolver; + + @Before + public void setup() { + typeConvertingMapELResolver = new TypeConvertingMapELResolver(false); + } + + @Test + public void itResolvesProperties() { + Map values = ImmutableMap.of("1", "value1", "2", "value2"); + assertThat(typeConvertingMapELResolver.getValue(new JinjavaELContext(), values, "2")) + .isEqualTo("value2"); + } + + @Test + public void itConvertsPropertyClassWhenResolvingProperty() { + Map values = ImmutableMap.of("1", "value1", "2", "value2"); + assertThat(typeConvertingMapELResolver.getValue(new JinjavaELContext(), values, 1)) + .isEqualTo("value1"); + } + + @Test + public void itHandlesNullKeyValuesWhenResolvingProperty() { + Map values = new HashMap<>(); + values.put(null, "nullValue"); + values.put("1", "value1"); + values.put("2", "value2"); + assertThat(typeConvertingMapELResolver.getValue(new JinjavaELContext(), values, 1)) + .isEqualTo("value1"); + } + + @Test + public void itHandlesMapWithOnlyNullKey() { + Map values = new HashMap<>(); + values.put(null, "nullValue"); + assertThat(typeConvertingMapELResolver.getValue(new JinjavaELContext(), values, 1)) + .isEqualTo(null); + } + + @Test + public void itResolvesNullPropertyValue() { + Map values = new HashMap<>(); + values.put(null, "nullValue"); + values.put("1", "value1"); + values.put("2", "value2"); + assertThat(typeConvertingMapELResolver.getValue(new JinjavaELContext(), values, null)) + .isEqualTo("nullValue"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AdditionOperatorTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AdditionOperatorTest.java index 09e403efc..7a8ce5dc1 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/AdditionOperatorTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/AdditionOperatorTest.java @@ -3,23 +3,35 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; import java.util.Collection; import java.util.Map; - +import org.junit.After; import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - @SuppressWarnings("unchecked") public class AdditionOperatorTest { + private static final long MAX_STRING_LENGTH = 10_000; private JinjavaInterpreter interpreter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); + interpreter = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(MAX_STRING_LENGTH).build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); } @Test @@ -27,6 +39,25 @@ public void itConcatsStrings() { assertThat(interpreter.resolveELExpression("'foo' + 'bar'", -1)).isEqualTo("foobar"); } + @Test + public void itLimitsLengthOfStrings() { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < MAX_STRING_LENGTH; i++) { + stringBuilder.append("0"); + } + + String first = stringBuilder.toString(); + assertThat(interpreter.resolveELExpression("'" + first + "' + ''", -1)) + .isEqualTo(first); + assertThat(interpreter.getErrors()).isEmpty(); + + assertThat(interpreter.resolveELExpression("'" + first + "' + 'TOOBIG'", -1)) + .isNull(); + + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains(OutputTooBigException.class.getSimpleName()); + } + @Test public void itAddsNumbers() { assertThat(interpreter.resolveELExpression("1 + 2", -1)).isEqualTo(3L); @@ -40,19 +71,31 @@ public void itConcatsNumberWithString() { @Test public void itCombinesTwoLists() { - assertThat((Collection) interpreter.resolveELExpression("['foo', 'bar'] + ['other', 'one']", -1)) - .containsExactly("foo", "bar", "other", "one"); + assertThat( + (Collection) interpreter.resolveELExpression( + "['foo', 'bar'] + ['other', 'one']", + -1 + ) + ) + .containsExactly("foo", "bar", "other", "one"); } @Test public void itAddsToList() { - assertThat((Collection) interpreter.resolveELExpression("['foo'] + 'bar'", -1)).containsExactly("foo", "bar"); + assertThat( + (Collection) interpreter.resolveELExpression("['foo'] + 'bar'", -1) + ) + .containsExactly("foo", "bar"); } @Test public void itCombinesTwoDicts() { - assertThat((Map) interpreter.resolveELExpression("{'k1':'v1'} + {'k2':'v2'}", -1)) - .containsOnly(entry("k1", "v1"), entry("k2", "v2")); + assertThat( + (Map) interpreter.resolveELExpression( + "{'k1':'v1'} + {'k2':'v2'}", + -1 + ) + ) + .containsOnly(entry("k1", "v1"), entry("k2", "v2")); } - } diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AllowlistEnumTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AllowlistEnumTest.java new file mode 100644 index 000000000..39ff75357 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AllowlistEnumTest.java @@ -0,0 +1,95 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.testobjects.OperationEnum; +import com.hubspot.jinjava.testobjects.SimpleColorEnum; +import java.lang.reflect.Method; +import java.time.Month; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class AllowlistEnumTest extends BaseJinjavaTest { + + private static final AllowlistReturnTypeValidator ENUM_RETURN_TYPE_VALIDATOR = + AllowlistReturnTypeValidator.create( + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassNames( + SimpleColorEnum.class.getCanonicalName(), + OperationEnum.class.getCanonicalName() + ) + .build() + ); + + private static final AllowlistMethodValidator ENUM_METHOD_VALIDATOR = + AllowlistMethodValidator.create( + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassNames( + SimpleColorEnum.class.getCanonicalName(), + OperationEnum.class.getCanonicalName() + ) + .build() + ); + + @Test + public void itAllowsReturningSimpleEnumValue() { + assertThat(ENUM_RETURN_TYPE_VALIDATOR.validateReturnType(SimpleColorEnum.RED)) + .isEqualTo(SimpleColorEnum.RED); + } + + @Test + public void itAllowsReturningConstantSpecificBodyEnumValue() { + assertThat(ENUM_RETURN_TYPE_VALIDATOR.validateReturnType(OperationEnum.PLUS)) + .isEqualTo(OperationEnum.PLUS); + } + + @Test + public void itRejectsReturningNonAllowlistedEnumValue() { + assertThat(ENUM_RETURN_TYPE_VALIDATOR.validateReturnType(Month.JANUARY)).isNull(); + } + + @Test + public void itAllowsInvokingSimpleEnumGetter() throws NoSuchMethodException { + Method getLabel = SimpleColorEnum.class.getMethod("getLabel"); + assertThat(ENUM_METHOD_VALIDATOR.validateMethod(getLabel)).isEqualTo(getLabel); + } + + @Test + public void itAllowsInvokingConstantSpecificBodyEnumMethod() + throws NoSuchMethodException { + Method apply = OperationEnum.PLUS.getClass().getMethod("apply", int.class, int.class); + assertThat(apply.getDeclaringClass().getCanonicalName()).isNull(); + assertThat(ENUM_METHOD_VALIDATOR.validateMethod(apply)).isEqualTo(apply); + } + + @Test + public void itRejectsInvokingNonAllowlistedEnumMethod() throws NoSuchMethodException { + Method getValue = Month.class.getMethod("getValue"); + assertThat(ENUM_METHOD_VALIDATOR.validateMethod(getValue)).isNull(); + } + + @Test + public void itRendersSimpleEnumGetter() { + Map context = new HashMap<>(); + context.put("color", SimpleColorEnum.RED); + assertThat(jinjava.render("{{ color.label }}", context)).isEqualTo("red-label"); + } + + @Test + public void itRendersConstantSpecificBodyEnumMethodInvocation() { + Map context = new HashMap<>(); + context.put("op", OperationEnum.PLUS); + assertThat(jinjava.render("{{ op.apply(2, 3) }}", context)).isEqualTo("5"); + } + + @Test + public void itRendersEnumValueAsName() { + Map context = new HashMap<>(); + context.put("op", OperationEnum.TIMES); + assertThat(jinjava.render("{{ op }}", context)).isEqualTo("TIMES"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AllowlistGroupTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AllowlistGroupTest.java new file mode 100644 index 000000000..454372f5f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AllowlistGroupTest.java @@ -0,0 +1,27 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class AllowlistGroupTest extends BaseJinjavaTest { + + @Test + public void itResolvesNamedParameterNameThroughAllowlist() { + Map context = new HashMap<>(); + context.put("np", new NamedParameter("greeting", "hello")); + String result = jinjava.render("{{ np.name }}", context); + assertThat(result).isEqualTo("greeting"); + } + + @Test + public void itResolvesNamedParameterValueThroughAllowlist() { + Map context = new HashMap<>(); + context.put("np", new NamedParameter("greeting", "hello")); + String result = jinjava.render("{{ np.value }}", context); + assertThat(result).isEqualTo("hello"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstDictTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstDictTest.java new file mode 100644 index 000000000..c5bbc0d6d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstDictTest.java @@ -0,0 +1,106 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.testobjects.AstDictTestObjects; +import java.util.Set; +import org.junit.Test; + +public class AstDictTest extends BaseJinjavaTest { + + @Test + public void itGetsDictValues() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter.getContext().put("foo", ImmutableMap.of("bar", "test")); + assertThat(interpreter.resolveELExpression("foo.bar", -1)).isEqualTo("test"); + } + } + + @Test + public void itGetsDictValuesWithEnumKeys() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter.getContext().put("foo", ImmutableMap.of(ErrorType.FATAL, "test")); + assertThat(interpreter.resolveELExpression("foo.FATAL", -1)).isEqualTo("test"); + } + } + + @Test + public void itGetsDictValuesWithEnumKeysUsingToString() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter + .getContext() + .put("foo", ImmutableMap.of(AstDictTestObjects.TestEnum.BAR, "test")); + assertThat(interpreter.resolveELExpression("foo.barName", -1)).isEqualTo("test"); + } + } + + @Test + public void itDoesItemsMethodCall() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter + .getContext() + .put("foo", ImmutableMap.of(AstDictTestObjects.TestEnum.BAR, "test")); + assertThat(interpreter.resolveELExpression("foo.items()", -1)) + .isInstanceOf(Set.class); + } + } + + @Test + public void itDoesKeysMethodCall() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter + .getContext() + .put("foo", ImmutableMap.of(AstDictTestObjects.TestEnum.BAR, "test")); + assertThat(interpreter.resolveELExpression("foo.keys()", -1)) + .isInstanceOf(Set.class); + } + } + + @Test + public void itHandlesEmptyMaps() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter.getContext().put("foo", ImmutableMap.of()); + assertThat(interpreter.resolveELExpression("foo.FATAL", -1)).isNull(); + assertThat(interpreter.getErrors()).isEmpty(); + } + } + + @Test + public void itGetsDictValuesWithEnumKeysInObjects() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaInterpreter interpreter = a.value(); + interpreter + .getContext() + .put( + "test", + new AstDictTestObjects.TestClass(ImmutableMap.of(ErrorType.FATAL, "test")) + ); + assertThat(interpreter.resolveELExpression("test.my_map.FATAL", -1)) + .isEqualTo("test"); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainParityTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainParityTest.java new file mode 100644 index 000000000..e9ee8bb76 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainParityTest.java @@ -0,0 +1,530 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.RenderResult; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; + +public class AstFilterChainParityTest { + + private Jinjava jinjavaOptimized; + private Jinjava jinjavaUnoptimized; + private Map context; + + @Before + public void setup() { + LegacyOverrides legacyOverrides = LegacyOverrides + .newBuilder() + .withUsePyishObjectMapper(true) + .withKeepNullableLoopValues(true) + .build(); + + jinjavaOptimized = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(true) + .withLegacyOverrides(legacyOverrides) + .build() + ); + + jinjavaUnoptimized = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(false) + .withLegacyOverrides(legacyOverrides) + .build() + ); + + context = new HashMap<>(); + context.put("name", " Hello World "); + context.put("text", "the quick brown fox jumps over the lazy dog"); + context.put("number", 12345); + context.put("float_num", 3.14159); + context.put("negative", -42); + context.put("items", Arrays.asList("apple", "banana", "cherry")); + context.put("empty_list", ImmutableList.of()); + context.put("numbers", Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6)); + context.put("html", ""); + context.put("null_value", null); + context.put( + "nested", + ImmutableMap.of("key", "value", "num", 100, "list", Arrays.asList(1, 2, 3)) + ); + context.put( + "objects", + Arrays.asList( + ImmutableMap.of("name", "Alice", "age", 30), + ImmutableMap.of("name", "Bob", "age", 25), + ImmutableMap.of("name", "Charlie", "age", 35) + ) + ); + context.put("mixed_case", "HeLLo WoRLd"); + context.put("whitespace", " lots of spaces "); + context.put("unicode", "héllo wörld 你好"); + context.put("special_chars", "a&bd\"e'f"); + context.put("json_string", "{\"key\": \"value\", \"num\": 42}"); + context.put("long_text", "word ".repeat(100)); + context.put("arg_value", 10); + } + + @Test + public void itProducesSameResultsForSingleFilters() { + List templates = ImmutableList.of( + "{{ name|trim }}", + "{{ name|lower }}", + "{{ name|upper }}", + "{{ name|length }}", + "{{ number|string }}", + "{{ number|abs }}", + "{{ float_num|round }}", + "{{ float_num|int }}", + "{{ items|first }}", + "{{ items|last }}", + "{{ items|length }}", + "{{ items|reverse }}", + "{{ items|sort }}", + "{{ html|escape }}", + "{{ html|e }}", + "{{ text|capitalize }}", + "{{ text|title }}", + "{{ text|wordcount }}", + "{{ negative|abs }}", + "{{ mixed_case|lower }}", + "{{ mixed_case|upper }}", + "{{ whitespace|trim }}", + "{{ unicode|upper }}", + "{{ unicode|lower }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForChainedFilters() { + List templates = ImmutableList.of( + "{{ name|trim|lower }}", + "{{ name|trim|upper }}", + "{{ name|trim|lower|capitalize }}", + "{{ name|trim|lower|upper }}", + "{{ text|upper|lower|capitalize }}", + "{{ text|capitalize|lower|upper }}", + "{{ number|string|length }}", + "{{ number|string|upper }}", + "{{ items|first|upper }}", + "{{ items|last|lower }}", + "{{ items|reverse|first }}", + "{{ items|sort|last }}", + "{{ items|sort|reverse|first }}", + "{{ html|escape|upper }}", + "{{ float_num|round|string|length }}", + "{{ whitespace|trim|lower|capitalize }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForFiltersWithPositionalArgs() { + List templates = ImmutableList.of( + "{{ text|truncate(20) }}", + "{{ text|truncate(20, True) }}", + "{{ text|truncate(20, True, '...') }}", + "{{ text|truncate(10, False) }}", + "{{ items|join(', ') }}", + "{{ items|join(' - ') }}", + "{{ items|join('') }}", + "{{ text|replace('the', 'a') }}", + "{{ text|replace('o', '0') }}", + "{{ text|split(' ') }}", + "{{ text|split(' ', 3) }}", + "{{ number|default(0) }}", + "{{ null_value|default('fallback') }}", + "{{ null_value|default(42) }}", + "{{ float_num|round(2) }}", + "{{ float_num|round(0) }}", + "{{ text|center(50) }}", + "{{ text|center(50, '-') }}", + "{{ numbers|batch(3) }}", + "{{ numbers|slice(3) }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForFiltersWithNamedParams() { + List templates = ImmutableList.of( + "{{ text|truncate(length=20) }}", + "{{ text|truncate(length=20, killwords=True) }}", + "{{ text|truncate(length=20, end='!!!') }}", + "{{ text|truncate(length=15, killwords=False, end='...') }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForMixedPositionalAndNamedParams() { + List templates = ImmutableList.of( + "{{ text|truncate(20, killwords=True) }}", + "{{ text|truncate(20, end='!') }}", + "{{ items|join(', ')|truncate(length=15) }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForChainedFiltersWithArgs() { + List templates = ImmutableList.of( + "{{ text|truncate(20)|upper }}", + "{{ text|upper|truncate(20) }}", + "{{ text|replace('the', 'a')|upper }}", + "{{ text|upper|replace('THE', 'a') }}", + "{{ text|truncate(30)|replace('...', '!')|upper }}", + "{{ items|join(', ')|upper }}", + "{{ items|join(', ')|truncate(10) }}", + "{{ items|sort|join(' - ')|upper }}", + "{{ items|reverse|join(', ')|lower }}", + "{{ numbers|sort|join('-') }}", + "{{ numbers|reverse|join(', ')|length }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForFilterArgsWithExpressions() { + List templates = ImmutableList.of( + "{{ text|truncate(arg_value) }}", + "{{ text|truncate(arg_value + 5) }}", + "{{ text|truncate(arg_value * 2) }}", + "{{ items|join(name|trim) }}", + "{{ text|replace(items|first, items|last) }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForNullAndUndefinedHandling() { + List templates = ImmutableList.of( + "{{ null_value|default('fallback') }}", + "{{ null_value|default('fallback')|upper }}", + "{{ undefined_var|default('missing') }}", + "{{ undefined_var|default('missing')|lower }}", + "{{ null_value|string }}", + "{{ null_value|e }}", + "{{ nested.missing|default('not found') }}", + "{{ nested.missing|default('')|length }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForSafeStringHandling() { + context.put("safe_html", "Bold"); + + List templates = ImmutableList.of( + "{{ safe_html|safe }}", + "{{ safe_html|safe|upper }}", + "{{ safe_html|upper|safe }}", + "{{ safe_html|safe|length }}", + "{{ safe_html|safe|trim }}", + "{{ safe_html|safe|lower|capitalize }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForCollectionFilters() { + List templates = ImmutableList.of( + "{{ items|list }}", + "{{ items|unique }}", + "{{ numbers|sum }}", + "{{ numbers|sort }}", + "{{ numbers|sort|reverse }}", + "{{ objects|map(attribute='name') }}", + "{{ objects|map(attribute='name')|join(', ') }}", + "{{ objects|selectattr('age', '>', 28) }}", + "{{ objects|rejectattr('age', '<', 30) }}", + "{{ numbers|select('>', 3) }}", + "{{ numbers|reject('==', 1) }}", + "{{ items|batch(2)|list }}", + "{{ numbers|slice(3)|list }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForStringManipulationFilters() { + List templates = ImmutableList.of( + "{{ text|format }}", + "{{ text|striptags }}", + "{{ html|striptags }}", + "{{ text|urlize }}", + "{{ special_chars|escape }}", + "{{ special_chars|urlencode }}", + "{{ text|regex_replace('\\\\s+', '_') }}", + "{{ text|replace(' ', '_') }}", + "{{ name|trim|replace(' ', '-')|lower }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForNumericFilters() { + List templates = ImmutableList.of( + "{{ number|filesizeformat }}", + "{{ float_num|round }}", + "{{ float_num|round(2) }}", + "{{ float_num|round(2, 'floor') }}", + "{{ float_num|round(2, 'ceil') }}", + "{{ negative|abs }}", + "{{ number|float }}", + "{{ float_num|int }}", + "{{ number|divide(100) }}", + "{{ number|multiply(2) }}", + "{{ float_num|log }}", + "{{ number|root }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForDateTimeFilters() { + context.put("timestamp", 1609459200000L); + context.put("date_string", "2021-01-01"); + + List templates = ImmutableList.of( + "{{ timestamp|datetimeformat }}", + "{{ timestamp|unixtimestamp }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForJsonFilters() { + List templates = ImmutableList.of( + "{{ nested|tojson }}", + "{{ items|tojson }}", + "{{ json_string|fromjson }}", + "{{ json_string|fromjson|tojson }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForMultipleFilterChainsInTemplate() { + List templates = ImmutableList.of( + "{{ name|trim|lower }} and {{ text|upper|truncate(10) }}", + "Hello {{ name|trim }}, you have {{ items|length }} items", + "{{ items|first|upper }} - {{ items|last|lower }}", + "{{ number|string }} is {{ number|string|length }} digits", + "Name: {{ name|trim|lower|capitalize }}, Count: {{ items|length }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForNestedPropertyAccess() { + List templates = ImmutableList.of( + "{{ nested.key|upper }}", + "{{ nested.num|string }}", + "{{ nested.list|first }}", + "{{ nested.list|join('-') }}", + "{{ nested.key|upper|lower|capitalize }}", + "{{ objects[0].name|upper }}", + "{{ objects[0].name|upper|truncate(3) }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForFilterChainInConditions() { + List templates = ImmutableList.of( + "{% if name|trim|length > 5 %}long{% else %}short{% endif %}", + "{% if items|length > 2 %}many{% else %}few{% endif %}", + "{% if name|trim|lower == 'hello world' %}match{% else %}no match{% endif %}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForFilterChainInLoops() { + List templates = ImmutableList.of( + "{% for item in items|sort %}{{ item|upper }}{% endfor %}", + "{% for item in items|reverse %}{{ item|capitalize }}{% endfor %}", + "{% for n in numbers|sort|unique %}{{ n }}{% endfor %}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForLongFilterChains() { + List templates = ImmutableList.of( + "{{ text|upper|lower|capitalize|trim }}", + "{{ text|trim|lower|upper|lower|capitalize }}", + "{{ name|trim|lower|upper|lower|upper|lower }}", + "{{ text|replace('the', 'a')|upper|lower|capitalize|trim }}", + "{{ items|sort|reverse|join(', ')|upper|truncate(20) }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itTracksResolvedValuesConsistently() { + String template = "{{ name|trim|lower|upper }}"; + + RenderResult optimizedResult = jinjavaOptimized.renderForResult(template, context); + RenderResult unoptimizedResult = jinjavaUnoptimized.renderForResult( + template, + context + ); + + assertThat(optimizedResult.getOutput()) + .as("Output should match") + .isEqualTo(unoptimizedResult.getOutput()); + + Set optimizedResolved = optimizedResult.getContext().getResolvedValues(); + Set unoptimizedResolved = unoptimizedResult.getContext().getResolvedValues(); + + assertThat(optimizedResolved).as("Resolved filter:trim").contains("filter:trim"); + assertThat(optimizedResolved).as("Resolved filter:lower").contains("filter:lower"); + assertThat(optimizedResolved).as("Resolved filter:upper").contains("filter:upper"); + + assertThat(unoptimizedResolved) + .as("Unoptimized resolved filter:trim") + .contains("filter:trim"); + assertThat(unoptimizedResolved) + .as("Unoptimized resolved filter:lower") + .contains("filter:lower"); + assertThat(unoptimizedResolved) + .as("Unoptimized resolved filter:upper") + .contains("filter:upper"); + } + + @Test + public void itHandlesUnknownFiltersConsistently() { + String template = "{{ name|unknownfilter }}"; + + RenderResult optimizedResult = jinjavaOptimized.renderForResult(template, context); + RenderResult unoptimizedResult = jinjavaUnoptimized.renderForResult( + template, + context + ); + + assertThat(optimizedResult.getOutput()) + .as("Both paths should return empty for unknown filter") + .isEqualTo(unoptimizedResult.getOutput()); + } + + @Test + public void itProducesSameResultsForEmptyInputs() { + context.put("empty_string", ""); + + List templates = ImmutableList.of( + "{{ empty_string|upper }}", + "{{ empty_string|trim }}", + "{{ empty_string|default('fallback') }}", + "{{ empty_string|length }}", + "{{ empty_list|join(', ') }}", + "{{ empty_list|first }}", + "{{ empty_list|last }}", + "{{ empty_list|length }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForSpecialCharacters() { + List templates = ImmutableList.of( + "{{ special_chars|escape }}", + "{{ special_chars|escape|upper }}", + "{{ special_chars|urlencode }}", + "{{ special_chars|replace('&', 'and') }}", + "{{ unicode|upper }}", + "{{ unicode|lower }}", + "{{ unicode|length }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForBase64Filters() { + context.put("plain_text", "Hello, World!"); + context.put("base64_text", "SGVsbG8sIFdvcmxkIQ=="); + + List templates = ImmutableList.of( + "{{ plain_text|b64encode }}", + "{{ base64_text|b64decode }}", + "{{ plain_text|b64encode|b64decode }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForSelectAndRejectFilters() { + List templates = ImmutableList.of( + "{{ numbers|select('even')|list }}", + "{{ numbers|select('odd')|list }}", + "{{ numbers|reject('even')|list }}", + "{{ numbers|select('>', 3)|list }}", + "{{ numbers|select('>=', 4)|list }}", + "{{ numbers|reject('>', 5)|list }}" + ); + + assertParityForTemplates(templates); + } + + @Test + public void itProducesSameResultsForAttrFilters() { + List templates = ImmutableList.of( + "{{ objects|map(attribute='name')|list }}", + "{{ objects|map(attribute='age')|list }}", + "{{ objects|selectattr('age', '>', 28)|map(attribute='name')|list }}", + "{{ objects|rejectattr('age', '<', 30)|map(attribute='name')|list }}", + "{{ objects|groupby('age') }}" + ); + + assertParityForTemplates(templates); + } + + private void assertParityForTemplates(List templates) { + for (String template : templates) { + String optimizedResult = jinjavaOptimized.render(template, context); + String unoptimizedResult = jinjavaUnoptimized.render(template, context); + assertThat(optimizedResult) + .as("Template: %s", template) + .isEqualTo(unoptimizedResult); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainPerformanceTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainPerformanceTest.java new file mode 100644 index 000000000..7373b359a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainPerformanceTest.java @@ -0,0 +1,173 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +/** + * Performance tests for the filter chain optimization. + * + * Run manually with: mvn test -Dtest=AstFilterChainPerformanceTest + * Or run the main() method directly for detailed output. + */ +public class AstFilterChainPerformanceTest { + + private Jinjava jinjavaOptimized; + private Jinjava jinjavaUnoptimized; + private Map context; + + @Before + public void setup() { + jinjavaOptimized = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withEnableFilterChainOptimization(true).build() + ); + + jinjavaUnoptimized = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(false) + .build() + ); + + context = new HashMap<>(); + context.put("name", " Hello World "); + context.put("text", "the quick brown fox jumps over the lazy dog"); + context.put("number", 12345); + context.put("items", new String[] { "apple", "banana", "cherry" }); + context.put("content", Map.of("text", "the quick brown fox jumps over the lazy dog")); + } + + public static void main(String[] args) { + AstFilterChainPerformanceTest test = new AstFilterChainPerformanceTest(); + test.setup(); + test.runPerformanceComparison(); + } + + /** + * Run this test manually to see detailed performance comparison. + * Use main() method or run with -Dtest=AstFilterChainPerformanceTest#runPerformanceComparison + */ + @Test + @Ignore("Manual performance test - run explicitly when needed") + public void runPerformanceComparison() { + int warmupIterations = 10000; + int testIterations = 100000; + + System.out.println("=== Filter Chain Performance Test ===\n"); + System.out.println("Warming up..."); + + runFilterTests(jinjavaOptimized, warmupIterations); + runFilterTests(jinjavaUnoptimized, warmupIterations); + + System.out.println( + "Running performance tests with " + testIterations + " iterations each\n" + ); + + comparePerformance("Single filter: {{ name|trim }}", testIterations); + comparePerformance("Two filters: {{ name|trim|lower }}", testIterations); + comparePerformance("Three filters: {{ name|trim|lower|capitalize }}", testIterations); + comparePerformance( + "Five filters: {{ text|upper|replace('THE', 'a')|trim|lower|title }}", + testIterations + ); + comparePerformance( + "Filters with args: {{ text|truncate(20)|upper }}", + testIterations + ); + comparePerformance( + "Multiple chains: {{ name|trim|lower }} and {{ text|upper|truncate(10) }}", + testIterations + ); + } + + @Test + public void optimizedVersionShouldBeFaster() { + int warmupIterations = 100; + int testIterations = 1000; + String template = "{{ content.text|upper|replace('THE', 'a')|trim|lower|title }}"; + + for (int i = 0; i < warmupIterations; i++) { + jinjavaOptimized.render(template, context); + jinjavaUnoptimized.render(template, context); + } + + long totalOptimizedTime = 0; + long totalUnoptimizedTime = 0; + int rounds = 3; + + for (int round = 0; round < rounds; round++) { + totalUnoptimizedTime += timeExecution(jinjavaUnoptimized, template, testIterations); + totalOptimizedTime += timeExecution(jinjavaOptimized, template, testIterations); + } + + long avgUnoptimizedTime = totalUnoptimizedTime / rounds; + long avgOptimizedTime = totalOptimizedTime / rounds; + + System.out.printf( + "Performance test: Optimized=%d ms, Unoptimized=%d ms, Speedup=%.2fx%n", + avgOptimizedTime, + avgUnoptimizedTime, + (1.0 * avgUnoptimizedTime) / avgOptimizedTime + ); + + assertThat(avgOptimizedTime) + .as( + "Optimized (%d ms) should be faster than unoptimized (%d ms)", + avgOptimizedTime, + avgUnoptimizedTime + ) + .isLessThan((avgUnoptimizedTime * 95) / 100); + } + + private void comparePerformance(String description, int iterations) { + String template = description.substring(description.indexOf("{{")); + if (description.contains(":")) { + template = description.substring(description.indexOf(":") + 2); + } + + System.out.println(description); + + long optimizedTime = timeExecution(jinjavaOptimized, template, iterations); + long unoptimizedTime = timeExecution(jinjavaUnoptimized, template, iterations); + + double speedup = (1.0 * unoptimizedTime) / optimizedTime; + System.out.printf( + " Optimized: %d ms, Unoptimized: %d ms, Speedup: %.2fx%n%n", + optimizedTime, + unoptimizedTime, + speedup + ); + } + + private long timeExecution(Jinjava jinjava, String template, int iterations) { + long startTime = System.currentTimeMillis(); + for (int i = 0; i < iterations; i++) { + jinjava.render(template, context); + } + return System.currentTimeMillis() - startTime; + } + + private void runFilterTests(Jinjava jinjava, int iterations) { + String[] templates = { + "{{ name|trim }}", + "{{ name|trim|lower }}", + "{{ name|trim|lower|capitalize }}", + "{{ text|upper|replace('THE', 'a')|trim|lower|title }}", + "{{ text|truncate(20)|upper }}", + }; + + for (String template : templates) { + for (int i = 0; i < iterations; i++) { + jinjava.render(template, context); + } + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java new file mode 100644 index 000000000..0544d1a5c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java @@ -0,0 +1,176 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; + +public class AstFilterChainTest { + + private Jinjava jinjava; + private Map context; + + @Before + public void setup() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(true) + .withKeepTrailingNewline(true) + .build() + ); + + context = new HashMap<>(); + context.put("name", " Hello World "); + context.put("text", "the quick brown fox jumps over the lazy dog"); + context.put("number", 12345); + context.put("items", new String[] { "apple", "banana", "cherry" }); + } + + @Test + public void itHandlesSingleFilter() { + String result = jinjava.render("{{ name|trim }}", context); + assertThat(result).isEqualTo("Hello World"); + } + + @Test + public void itHandlesChainedFilters() { + String result = jinjava.render("{{ name|trim|lower }}", context); + assertThat(result).isEqualTo("hello world"); + } + + @Test + public void itHandlesFiltersWithArguments() { + String result = jinjava.render("{{ text|truncate(20)|upper }}", context); + assertThat(result).isNotEmpty(); + assertThat(result).isUpperCase(); + } + + @Test + public void itHandlesComplexFilterChain() { + String result = jinjava.render( + "{{ text|upper|replace('THE', 'a')|trim|lower|capitalize }}", + context + ); + assertThat(result).isNotEmpty(); + } + + @Test + public void itHandlesFilterWithJoin() { + String result = jinjava.render("{{ items|join(', ')|upper }}", context); + assertThat(result).isEqualTo("APPLE, BANANA, CHERRY"); + } + + @Test + public void itHandlesFilterWithStringConversion() { + String result = jinjava.render("{{ number|string|length }}", context); + assertThat(result).isEqualTo("5"); + } + + @Test + public void itHandlesUnknownFilterInChain() { + context.put("module", new PyishDate(ZonedDateTime.parse("2024-01-15T10:30:00Z"))); + RenderResult renderResult = jinjava.renderForResult( + "{% set mid = module | local_dt|unixtimestamp | pprint | md5 %}{{ mid }}", + context + ); + assertThat(renderResult.getOutput()) + .as("Should produce MD5 output since chain continues past unknown filter") + .hasSize(32); + assertThat( + renderResult + .getErrors() + .stream() + .noneMatch(e -> e.getMessage().contains("Unknown filter")) + ) + .as("Should not report 'Unknown filter' error") + .isTrue(); + } + + @Test + public void itMatchesNonChainedBehaviorForUnknownFilter() { + String template = "{{ name | unknown_filter | lower | md5 }}"; + Jinjava jinjavaUnoptimized = new Jinjava( + BaseJinjavaTest.newConfigBuilder().withEnableFilterChainOptimization(false).build() + ); + RenderResult optimizedResult = jinjava.renderForResult(template, context); + RenderResult unoptimizedResult = jinjavaUnoptimized.renderForResult( + template, + context + ); + assertThat(optimizedResult.getOutput()) + .as("Optimized should match un-optimized for unknown filter in chain") + .isEqualTo(unoptimizedResult.getOutput()); + } + + @Test + public void itSkipsDisabledFilterAndContinuesChain() { + Map> disabled = ImmutableMap.of( + Context.Library.FILTER, + ImmutableSet.of("lower") + ); + Jinjava jinjavaWithDisabled = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(true) + .withKeepTrailingNewline(true) + .withDisabled(disabled) + .build() + ); + + RenderResult result = jinjavaWithDisabled.renderForResult( + "{{ name|trim|lower|capitalize }}", + context + ); + + assertThat(result.getErrors()).isNotEmpty(); + assertThat(result.getErrors().get(0).getItem()).isEqualTo(ErrorItem.FILTER); + assertThat(result.getErrors().get(0).getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(result.getErrors().get(0).getMessage()).contains("lower"); + } + + @Test + public void itMatchesNonChainedBehaviorForDisabledFilter() { + Map> disabled = ImmutableMap.of( + Context.Library.FILTER, + ImmutableSet.of("lower") + ); + String template = "{{ name|trim|lower|capitalize }}"; + + Jinjava optimized = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(true) + .withDisabled(disabled) + .build() + ); + Jinjava unoptimized = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(false) + .withDisabled(disabled) + .build() + ); + + RenderResult optimizedResult = optimized.renderForResult(template, context); + RenderResult unoptimizedResult = unoptimized.renderForResult(template, context); + + assertThat(optimizedResult.getOutput()) + .as("Optimized should match un-optimized for disabled filter in chain") + .isEqualTo(unoptimizedResult.getOutput()); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperatorTest.java b/src/test/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperatorTest.java new file mode 100644 index 000000000..5d9b1094f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/CollectionMembershipOperatorTest.java @@ -0,0 +1,81 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; + +public class CollectionMembershipOperatorTest { + + static class NoKeySetMap extends AbstractMap { + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public Set keySet() { + return Collections.emptySet(); + } + + @Override + public Set> entrySet() { + return Collections.emptySet(); + } + } + + private JinjavaInterpreter interpreter; + + @Before + public void setup() { + interpreter = new Jinjava().newInterpreter(); + } + + @Test + public void itChecksIfStringContainsChar() { + assertThat(interpreter.resolveELExpression("'a' in 'pastrami'", -1)).isEqualTo(true); + assertThat(interpreter.resolveELExpression("'o' in 'pastrami'", -1)).isEqualTo(false); + } + + @Test + public void itChecksIfArrayContainsValue() { + assertThat(interpreter.resolveELExpression("11 in [11, 12, 13]", -1)).isEqualTo(true); + assertThat(interpreter.resolveELExpression("14 in [11, 12, 13]", -1)) + .isEqualTo(false); + } + + @Test + public void itChecksIfDictionaryContainsKey() { + assertThat(interpreter.resolveELExpression("'a' in {'a': 1, 'b': 2}", -1)) + .isEqualTo(true); + assertThat(interpreter.resolveELExpression("'c' in {'a': 1, 'b': 2}", -1)) + .isEqualTo(false); + } + + @Test + public void itChecksIfDictionaryContainsNullKey() { + Map map = new HashMap(); + map.put(null, "null"); + map.put("a", 1); + interpreter.getContext().put("map", map); + assertThat(interpreter.resolveELExpression("'a' in map", -1)).isEqualTo(true); + assertThat(interpreter.resolveELExpression("null in map", -1)).isEqualTo(true); + assertThat(interpreter.resolveELExpression("'b' in map", -1)).isEqualTo(false); + } + + @Test + public void itCheckEmptyKeySet() { + // The map is "not" empty, but keySet() is empty. + Map map = new NoKeySetMap<>(); + interpreter.getContext().put("map", map); + assertThat(interpreter.resolveELExpression("'a' in map", -1)).isEqualTo(false); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperatorTest.java b/src/test/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperatorTest.java new file mode 100644 index 000000000..92a68aa5a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/CollectionNonMembershipOperatorTest.java @@ -0,0 +1,42 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.junit.Before; +import org.junit.Test; + +public class CollectionNonMembershipOperatorTest { + + private JinjavaInterpreter interpreter; + + @Before + public void setup() { + interpreter = new Jinjava().newInterpreter(); + } + + @Test + public void itChecksIfStringDoesntContainChar() { + assertThat(interpreter.resolveELExpression("'a' not in 'pastrami'", -1)) + .isEqualTo(false); + assertThat(interpreter.resolveELExpression("'o' not in 'pastrami'", -1)) + .isEqualTo(true); + } + + @Test + public void itChecksIfArrayDoesntContainValue() { + assertThat(interpreter.resolveELExpression("11 not in [11, 12, 13]", -1)) + .isEqualTo(false); + assertThat(interpreter.resolveELExpression("14 not in [11, 12, 13]", -1)) + .isEqualTo(true); + } + + @Test + public void itChecksIfDictionaryDoesntContainKey() { + assertThat(interpreter.resolveELExpression("'a' not in {'a': 1, 'b': 2}", -1)) + .isEqualTo(false); + assertThat(interpreter.resolveELExpression("'c' not in {'a': 1, 'b': 2}", -1)) + .isEqualTo(true); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/ExtendedParserTest.java b/src/test/java/com/hubspot/jinjava/el/ext/ExtendedParserTest.java new file mode 100644 index 000000000..39cdc6124 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/ExtendedParserTest.java @@ -0,0 +1,233 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import de.odysseus.el.tree.impl.Builder; +import de.odysseus.el.tree.impl.Builder.Feature; +import de.odysseus.el.tree.impl.ast.AstBinary; +import de.odysseus.el.tree.impl.ast.AstDot; +import de.odysseus.el.tree.impl.ast.AstIdentifier; +import de.odysseus.el.tree.impl.ast.AstMethod; +import de.odysseus.el.tree.impl.ast.AstNested; +import de.odysseus.el.tree.impl.ast.AstNode; +import de.odysseus.el.tree.impl.ast.AstParameters; +import de.odysseus.el.tree.impl.ast.AstString; +import org.assertj.core.api.Assertions; +import org.junit.Test; + +public class ExtendedParserTest { + + @Test + public void itParseSingleBinaryEqualCondition() { + AstNode astNode = buildExpressionNodes("#{'a' == 'b'}"); + + assertThat(astNode).isInstanceOf(AstBinary.class); + assertLeftAndRightByOperator((AstBinary) astNode, "a", "b", AstBinary.EQ); + } + + @Test + public void itParseBinaryOrEqualCondition() { + AstNode astNode = buildExpressionNodes("#{'a' == 'b' or 'c' == 'd'}"); + + assertThat(astNode).isInstanceOf(AstBinary.class); + + AstBinary astBinary = (AstBinary) astNode; + AstNode left = astBinary.getChild(0); + AstNode right = astBinary.getChild(1); + + assertThat(astBinary.getOperator()).isEqualTo(OrOperator.OP); + assertThat(left).isInstanceOf(AstBinary.class); + assertThat(right).isInstanceOf(AstBinary.class); + + assertLeftAndRightByOperator((AstBinary) left, "a", "b", AstBinary.EQ); + assertLeftAndRightByOperator((AstBinary) right, "c", "d", AstBinary.EQ); + } + + @Test + public void itParseSingleBinaryWithExpressionCondition() { + AstNode astNode = buildExpressionNodes("#{'a' is equalto 'b'}"); + + assertThat(astNode).isInstanceOf(AstMethod.class); + assertForExpression(astNode, "a", "b", "exptest:equalto"); + } + + @Test + public void itParseBinaryOrWithEqualSymbolAndExpressionCondition() { + AstNode astNode = buildExpressionNodes("#{'a' == 'b' or 'c' is equalto 'd'}"); + + assertThat(astNode).isInstanceOf(AstBinary.class); + + AstBinary astBinary = (AstBinary) astNode; + AstNode left = astBinary.getChild(0); + AstNode right = astBinary.getChild(1); + + assertThat(astBinary.getOperator()).isEqualTo(OrOperator.OP); + assertThat(left).isInstanceOf(AstBinary.class); + assertThat(right).isInstanceOf(AstMethod.class); + + assertLeftAndRightByOperator((AstBinary) left, "a", "b", AstBinary.EQ); + assertForExpression(right, "c", "d", "exptest:equalto"); + } + + @Test + public void itParseBinaryOrWithExpressionsCondition() { + AstNode astNode = buildExpressionNodes("#{'a' is equalto 'b' or 'c' is equalto 'd'}"); + + assertThat(astNode).isInstanceOf(AstBinary.class); + + AstBinary astBinary = (AstBinary) astNode; + AstNode left = astBinary.getChild(0); + AstNode right = astBinary.getChild(1); + + assertThat(astBinary.getOperator()).isEqualTo(OrOperator.OP); + assertThat(left).isInstanceOf(AstMethod.class); + assertThat(right).isInstanceOf(AstMethod.class); + + assertForExpression(left, "a", "b", "exptest:equalto"); + assertForExpression(right, "c", "d", "exptest:equalto"); + } + + @Test + public void itParseBinaryOrWithNegativeExpressionsCondition() { + AstNode astNode = buildExpressionNodes( + "#{'a' is not equalto 'b' or 'c' is not equalto 'd'}" + ); + + assertThat(astNode).isInstanceOf(AstBinary.class); + + AstBinary astBinary = (AstBinary) astNode; + AstNode left = astBinary.getChild(0); + AstNode right = astBinary.getChild(1); + + assertThat(astBinary.getOperator()).isEqualTo(OrOperator.OP); + assertThat(left).isInstanceOf(AstMethod.class); + assertThat(right).isInstanceOf(AstMethod.class); + + assertForExpression(left, "a", "b", "exptest:equalto"); + assertForExpression(right, "c", "d", "exptest:equalto"); + } + + @Test + public void itParsesNestedCommasNotAsTuple() { + AstNode astNode = buildExpressionNodes("#{(range(0,range(0,2)[1]))}"); + assertThat(astNode).isInstanceOf(AstNested.class); + } + + @Test + public void itChecksForTupleUntilFinalParentheses() { + AstNode astNode = buildExpressionNodes("#{((0),2)}"); + assertThat(astNode).isInstanceOf(AstTuple.class); + } + + @Test + public void itParsesExpTestLikeDictionary() { + // Don't want to accidentally try to parse these as a filter or exptest + AstNode astNode = buildExpressionNodes( + "#{{filter:length.filter, exptest:equalto.evaluate}}" + ); + assertThat(astNode).isInstanceOf(AstDict.class); + } + + @Test + public void itResolvesAlternateExpTestSyntax() { + AstNode regularSyntax = buildExpressionNodes("#{2 is even}"); + + assertThat(regularSyntax).isInstanceOf(AstMethod.class); + assertThat(regularSyntax.getChild(0)).isInstanceOf(AstDot.class); + assertThat(regularSyntax.getChild(1)).isInstanceOf(AstParameters.class); + AstNode alternateSyntax = buildExpressionNodes("#{exptest:even.evaluate(2, null)}"); + + assertThat(alternateSyntax).isInstanceOf(AstMethod.class); + assertThat(alternateSyntax.getChild(0)).isInstanceOf(AstDot.class); + assertThat(alternateSyntax.getChild(1)).isInstanceOf(AstParameters.class); + } + + @Test + public void itResolvesAlternateExpTestSyntaxForTrueAndFalseExpTests() { + AstNode falseExpTest = buildExpressionNodes("#{exptest:false.evaluate(2, null)}"); + assertThat(falseExpTest).isInstanceOf(AstMethod.class); + assertThat(falseExpTest.getChild(0)).isInstanceOf(AstDot.class); + assertThat(falseExpTest.getChild(1)).isInstanceOf(AstParameters.class); + + AstNode trueExpTest = buildExpressionNodes("#{exptest:true.evaluate(2, null)}"); + assertThat(trueExpTest).isInstanceOf(AstMethod.class); + assertThat(trueExpTest.getChild(0)).isInstanceOf(AstDot.class); + assertThat(trueExpTest.getChild(1)).isInstanceOf(AstParameters.class); + } + + @Test + public void itResolvesAlternateExpTestSyntaxForInExpTest() { + AstNode inExpTest = buildExpressionNodes("#{exptest:in.evaluate(2, null, [])}"); + assertThat(inExpTest).isInstanceOf(AstMethod.class); + assertThat(inExpTest.getChild(0)).isInstanceOf(AstDot.class); + assertThat(inExpTest.getChild(1)).isInstanceOf(AstParameters.class); + } + + private void assertForExpression( + AstNode astNode, + String leftExpected, + String rightExpected, + String expression + ) { + AstIdentifier astIdentifier = (AstIdentifier) astNode.getChild(0).getChild(0); + assertThat(astIdentifier.getName()).isEqualTo(expression); + + AstParameters astParameters = (AstParameters) astNode.getChild(1); + assertThat(astParameters.getChild(0)).isInstanceOf(AstString.class); + assertThat(astParameters.getChild(1)).isInstanceOf(AstIdentifier.class); + assertThat(astParameters.getChild(2)).isInstanceOf(AstString.class); + + assertThat(astParameters.getChild(0).eval(null, null)).isEqualTo(leftExpected); + assertThat(((AstIdentifier) astParameters.getChild(1)).getName()) + .isEqualTo("____int3rpr3t3r____"); + assertThat(astParameters.getChild(2).eval(null, null)).isEqualTo(rightExpected); + } + + private void assertLeftAndRightByOperator( + AstBinary astBinary, + String leftExpected, + String rightExpected, + AstBinary.Operator operator + ) { + AstNode left = astBinary.getChild(0); + AstNode right = astBinary.getChild(1); + + assertThat(astBinary.getOperator()).isEqualTo(operator); + assertThat(left).isInstanceOf(AstString.class); + assertThat(right).isInstanceOf(AstString.class); + assertThat(left.eval(null, null)).isEqualTo(leftExpected); + assertThat(right.eval(null, null)).isEqualTo(rightExpected); + } + + private AstNode buildExpressionNodes(String input) { + ExtendedCustomParser extendedParser = new ExtendedCustomParser( + new Builder(Feature.METHOD_INVOCATIONS), + input + ); + extendedParser.consumeTokenExpose(); + extendedParser.consumeTokenExpose(); + + try { + return extendedParser.expr(true); + } catch (Exception exception) { + fail(exception.getMessage(), exception); + return null; + } + } + + private static class ExtendedCustomParser extends ExtendedParser { + + private ExtendedCustomParser(Builder context, String input) { + super(context, input); + } + + private void consumeTokenExpose() { + try { + super.consumeToken(); + } catch (Exception exception) { + Assertions.fail(exception.getMessage(), exception); + } + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java b/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java new file mode 100644 index 000000000..0456b1491 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java @@ -0,0 +1,151 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.el.JinjavaELContext; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.testobjects.JinjavaBeanELResolverTestObjects; +import javax.el.ELContext; +import javax.el.PropertyNotFoundException; +import org.junit.Before; +import org.junit.Test; + +public class JinjavaBeanELResolverTest { + + private JinjavaBeanELResolver jinjavaBeanELResolver; + private ELContext elContext; + private Jinjava jinjava; + + @Before + public void setUp() throws Exception { + jinjavaBeanELResolver = new JinjavaBeanELResolver(); + elContext = new JinjavaELContext(); + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + } + + @Test + public void itInvokesProperStringReplace() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + "abcd", + "replace", + null, + new Object[] { "abcd", "efgh" } + ) + ) + .isEqualTo("efgh"); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + "abcd", + "replace", + null, + new Object[] { 'a', 'e' } + ) + ) + .isEqualTo("ebcd"); + } + } + + @Test + public void itInvokesBestMethodWithSingleParam() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaBeanELResolverTestObjects.TempItInvokesBestMethodWithSingleParam var = + new JinjavaBeanELResolverTestObjects.TempItInvokesBestMethodWithSingleParam(); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { 1 } + ) + ) + .isEqualTo("int"); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { "1" } + ) + ) + .isEqualTo("String"); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { new Object() } + ) + ) + .isEqualTo("Object"); + } + } + + @Test + public void itPrefersPrimitives() { + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + JinjavaBeanELResolverTestObjects.TempItPrefersPrimitives var = + new JinjavaBeanELResolverTestObjects.TempItPrefersPrimitives(); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { 1, 2 } + ) + ) + .isEqualTo("int Integer"); + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { 1, Integer.valueOf(2) } + ) + ) + .isEqualTo("int Integer"); // should be "int object", but we can't figure that out + assertThat( + jinjavaBeanELResolver.invoke( + elContext, + var, + "getResult", + null, + new Object[] { Integer.valueOf(1), 2 } + ) + ) + .isEqualTo("int Integer"); // should be "Number int", but we can't figure that out + } + } + + @Test + public void itDoesNotAllowAccessingPropertiesOfInterpreter() { + try ( + AutoCloseableSupplier.AutoCloseableImpl a = JinjavaInterpreter + .closeablePushCurrent(jinjava.newInterpreter()) + .get() + ) { + assertThatThrownBy(() -> + jinjavaBeanELResolver.getValue(elContext, a.value(), "config") + ) + .isInstanceOf(PropertyNotFoundException.class); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/PowerOfTest.java b/src/test/java/com/hubspot/jinjava/el/ext/PowerOfTest.java new file mode 100644 index 000000000..2163eb12d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/PowerOfTest.java @@ -0,0 +1,96 @@ +package com.hubspot.jinjava.el.ext; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.Maps; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class PowerOfTest { + + @Before + public void setUp() { + jinja = new Jinjava(); + } + + @Test + public void testPowerOfInteger() { + Map context = Maps.newHashMap(); + context.put("base", 2); + context.put("evenExponent", 8); + context.put("oddExponent", 7); + context.put("negativeBase", -2); + context.put("negativeExponent", -8); + + String[][] testCases = { + { "{% set x = base ** evenExponent %}{{x}}", "256" }, + { "{% set x = 2 ** 8 %}{{x}}", "256" }, + { "{% set x = base ** 8 %}{{x}}", "256" }, + { "{% set x = 2 ** evenExponent %}{{x}}", "256" }, + { "{% set x = negativeBase ** evenExponent %}{{x}}", "256" }, + { "{% set x = -2 ** 8 %}{{x}}", "256" }, + { "{% set x = base ** negativeExponent %}{{x}}", "0" }, + { "{% set x = 2 ** -8 %}{{x}}", "0" }, + { "{% set x = negativeBase ** oddExponent %}{{x}}", "-128" }, + { "{% set x = -2 ** 7 %}{{x}}", "-128" }, + }; + + for (String[] testCase : testCases) { + String template = testCase[0]; + String expected = testCase[1]; + String rendered = jinja.render(template, context); + assertEquals(expected, rendered); + } + } + + @Test + public void testPowerOfFractional() { + Map context = Maps.newHashMap(); + context.put("base", 2); + context.put("evenExponent", 8.0); + context.put("oddExponent", 7.0); + context.put("negativeBase", -2); + context.put("negativeExponent", -8.0); + + String[][] testCases = { + { "{% set x = base ** evenExponent %}{{x}}", "256.0" }, + { "{% set x = 2 ** 8.0 %}{{x}}", "256.0" }, + { "{% set x = base ** 8.0 %}{{x}}", "256.0" }, + { "{% set x = 2 ** evenExponent %}{{x}}", "256.0" }, + { "{% set x = negativeBase ** evenExponent %}{{x}}", "256.0" }, + { "{% set x = -2 ** 8.0 %}{{x}}", "256.0" }, + { "{% set x = base ** negativeExponent %}{{x}}", "0.00390625" }, + { "{% set x = 2 ** -8.0 %}{{x}}", "0.00390625" }, + { "{% set x = negativeBase ** oddExponent %}{{x}}", "-128.0" }, + { "{% set x = -2 ** 7.0 %}{{x}}", "-128.0" }, + }; + + for (String[] testCase : testCases) { + String template = testCase[0]; + String expected = testCase[1]; + String rendered = jinja.render(template, context); + assertEquals(expected, rendered); + } + } + + @Test + public void testPowerOfStringFails() { + Map context = Maps.newHashMap(); + context.put("base", "2"); + context.put("exponent", "8"); + + String template = "{% set x = base ** exponent %}{{x}}"; + try { + jinja.render(template, context); + } catch (FatalTemplateErrorsException e) { + String msg = e.getMessage(); + assertTrue(msg.contains("Unsupported operand type(s)")); + } + } + + private Jinjava jinja; +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/RangeStringTest.java b/src/test/java/com/hubspot/jinjava/el/ext/RangeStringTest.java new file mode 100644 index 000000000..b5f98d4a6 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/RangeStringTest.java @@ -0,0 +1,97 @@ +/** + * 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.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** + * Created by anev on 11/05/16. + */ +public class RangeStringTest { + + private Jinjava jinjava; + private Map context; + + @Before + public void setup() { + jinjava = new Jinjava(); + context = ImmutableMap.of("theString", "theSimpleString", "emptyString", ""); + } + + @Test + public void testStringRangeSimple() { + assertThat(jinjava.render("{{ theString[0:4] }}", context)).isEqualTo("theS"); + } + + @Test + public void testStringRangeOutOfRange() { + assertThat(jinjava.render("{{ theString[0:400] }}", context)) + .isEqualTo("theSimpleString"); + } + + @Test + public void testStringRangeInvalidRange() { + assertThat(jinjava.render("{{ theString[4:2] }}", context)).isEmpty(); + } + + @Test + public void testStringRangeNegative() { + assertThat(jinjava.render("{{ theString[-7:-4] }}", context)).isEqualTo("eSt"); + } + + @Test + public void testStringEmpty() { + assertThat(jinjava.render("{{ emptyString[-1:0] }}", context)).isEmpty(); + } + + @Test + public void testStringRangeRightOnly() { + assertThat(jinjava.render("{{ theString[3:] }}", context)).isEqualTo("SimpleString"); + } + + @Test + public void testStringRangeLeftOnly() { + assertThat(jinjava.render("{{ theString[:3] }}", context)).isEqualTo("the"); + } + + @Test + public void testStringRangeNoRange() { + assertThat(jinjava.render("{{ theString[:] }}", context)) + .isEqualTo("theSimpleString"); + } + + @Test + public void testStringRangeMultiLine() { + Map localContext = ImmutableMap.of( + "theString", + "multi\nline\nstring" + ); + assertThat(jinjava.render("{{ theString[3:8] }}", localContext)).isEqualTo("ti\nli"); + } + + @Test + public void testStringRangeCyrillic() { + Map localContext = ImmutableMap.of( + "theString", + "Строка с non ascii символами" + ); + assertThat(jinjava.render("{{ theString[1:4] }}", localContext)).isEqualTo("тро"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/TruncDivTest.java b/src/test/java/com/hubspot/jinjava/el/ext/TruncDivTest.java new file mode 100644 index 000000000..f9fbaae96 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/TruncDivTest.java @@ -0,0 +1,103 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +import com.google.common.collect.Maps; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class TruncDivTest { + + private Jinjava jinja; + + @Before + public void setUp() { + jinja = new Jinjava(); + } + + /** + * Test the truncated division operator "//" with integer values + */ + @Test + public void testTruncDivInteger() { + Map context = Maps.newHashMap(); + context.put("dividend", 5); + context.put("divisor", 2); + context.put("negativeDividend", -5); + context.put("negativeDivisor", -2); + + String[][] testCases = { + { "{% set x = dividend // divisor %}{{x}}", "2" }, + { "{% set x = 5 // 2 %}{{x}}", "2" }, + { "{% set x = dividend // 2 %}{{x}}", "2" }, + { "{% set x = 5 // divisor %}{{x}}", "2" }, + { "{% set x = negativeDividend // divisor %}{{x}}", "-3" }, + { "{% set x = -5 // 2 %}{{x}}", "-3" }, + { "{% set x = dividend // negativeDivisor %}{{x}}", "-3" }, + { "{% set x = 5 // -2 %}{{x}}", "-3" }, + { "{% set x = negativeDividend // negativeDivisor %}{{x}}", "2" }, + { "{% set x = -5 // -2 %}{{x}}", "2" }, + }; + + for (String[] testCase : testCases) { + String template = testCase[0]; + String expected = testCase[1]; + String rendered = jinja.render(template, context); + assertEquals(expected, rendered); + } + } + + /** + * Test the truncated division operator "//" with fractional values + */ + @Test + public void testTruncDivFractional() { + Map context = Maps.newHashMap(); + context.put("dividend", 5.0); + context.put("divisor", 2); + context.put("negativeDividend", -5.0); + context.put("negativeDivisor", -2); + + String[][] testCases = { + { "{% set x = dividend // divisor %}{{x}}", "2.0" }, + { "{% set x = 5.0 // 2 %}{{x}}", "2.0" }, + { "{% set x = dividend // 2 %}{{x}}", "2.0" }, + { "{% set x = 5.0 // divisor %}{{x}}", "2.0" }, + { "{% set x = negativeDividend // divisor %}{{x}}", "-3.0" }, + { "{% set x = -5.0 // 2 %}{{x}}", "-3.0" }, + { "{% set x = dividend // negativeDivisor %}{{x}}", "-3.0" }, + { "{% set x = 5.0 // -2 %}{{x}}", "-3.0" }, + { "{% set x = negativeDividend // negativeDivisor %}{{x}}", "2.0" }, + { "{% set x = -5.0 // -2 %}{{x}}", "2.0" }, + }; + + for (String[] testCase : testCases) { + String template = testCase[0]; + String expected = testCase[1]; + String rendered = jinja.render(template, context); + assertEquals(expected, rendered); + } + } + + /** + * Test the truncated division operator "//" with strings + */ + @Test + public void testTruncDivStringFails() { + Map context = Maps.newHashMap(); + context.put("dividend", "5"); + context.put("divisor", "2"); + + String template = "{% set x = dividend // divisor %}"; + try { + jinja.render(template, context); + } catch (FatalTemplateErrorsException e) { + assertThat(e.getMessage()) + .contains("Unsupported operand type(s) for //: '5' (String) and '2' (String)"); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/ValidatorConfigBannedConstructsTest.java b/src/test/java/com/hubspot/jinjava/el/ext/ValidatorConfigBannedConstructsTest.java new file mode 100644 index 000000000..a22fbe017 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/ValidatorConfigBannedConstructsTest.java @@ -0,0 +1,227 @@ +package com.hubspot.jinjava.el.ext; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.exptest.ExpTest; +import com.hubspot.jinjava.lib.filter.Filter; +import java.lang.reflect.Method; +import org.junit.Test; + +public class ValidatorConfigBannedConstructsTest { + + // MethodValidatorConfig: allowedMethods() path + + @Test + public void itRejectsObjectMethodInAllowedMethods() throws NoSuchMethodException { + Method toStringMethod = Object.class.getMethod("toString"); + assertThatThrownBy(() -> + MethodValidatorConfig.builder().addAllowedMethods(toStringMethod).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsClassMethodInAllowedMethods() throws NoSuchMethodException { + Method getNameMethod = Class.class.getMethod("getName"); + assertThatThrownBy(() -> + MethodValidatorConfig.builder().addAllowedMethods(getNameMethod).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + // MethodValidatorConfig: allowedDeclaredMethodsFromCanonicalClassNames() path + + @Test + public void itRejectsObjectClassInAllowedDeclaredMethodClassNames() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassNames( + Object.class.getCanonicalName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsClassClassInAllowedDeclaredMethodClassNames() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassNames( + Class.class.getCanonicalName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsObjectMapperInAllowedDeclaredMethodClassNames() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassNames( + ObjectMapper.class.getCanonicalName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsJinjavaInterpreterInAllowedDeclaredMethodClassNames() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassNames( + JinjavaInterpreter.class.getCanonicalName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + // MethodValidatorConfig: allowedDeclaredMethodsFromCanonicalClassPrefixes() path + + @Test + public void itRejectsReflectPackageInAllowedDeclaredMethodPrefixes() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + Method.class.getPackageName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsJacksonDatabindPackageInAllowedDeclaredMethodPrefixes() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + ObjectMapper.class.getPackageName() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsEvilJinjavaFilterPathInAllowedDeclaredMethodPrefixes() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + Filter.class.getPackageName() + "_evil" + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsEvilJinjavaExptestPathInAllowedDeclaredMethodPrefixes() { + assertThatThrownBy(() -> + MethodValidatorConfig + .builder() + .addAllowedDeclaredMethodsFromCanonicalClassPrefixes( + ExpTest.class.getPackageName() + "_evil" + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + // ReturnTypeValidatorConfig: allowedCanonicalClassNames() path + + @Test + public void itRejectsObjectClassInAllowedReturnTypeClassNames() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassNames(Object.class.getCanonicalName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsClassClassInAllowedReturnTypeClassNames() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassNames(Class.class.getCanonicalName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsObjectMapperInAllowedReturnTypeClassNames() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassNames(ObjectMapper.class.getCanonicalName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsJinjavaInterpreterInAllowedReturnTypeClassNames() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassNames(JinjavaInterpreter.class.getCanonicalName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + // ReturnTypeValidatorConfig: allowedCanonicalClassPrefixes() path + + @Test + public void itRejectsReflectPackageInAllowedReturnTypePrefixes() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassPrefixes(Method.class.getPackageName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } + + @Test + public void itRejectsJacksonDatabindPackageInAllowedReturnTypePrefixes() { + assertThatThrownBy(() -> + ReturnTypeValidatorConfig + .builder() + .addAllowedCanonicalClassPrefixes(ObjectMapper.class.getPackageName()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Banned classes or prefixes"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinaryTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinaryTest.java new file mode 100644 index 000000000..f11f372cd --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBinaryTest.java @@ -0,0 +1,107 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstBinaryTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + + interpreter.getContext().put("deferred", DeferredValue.instance()); + interpreter.getContext().put("foo", "bar"); + List fooList = new ArrayList<>(); + fooList.add("val"); + interpreter.getContext().put("foo_list", new PyList(fooList)); + } + + @Test + public void itDoesNotShortCircuitIdentifier() { + try { + interpreter.resolveELExpression("foo && deferred && foo", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("'bar' && deferred && 'bar'"); + } + } + + @Test + public void itShortCircuitsDeferredAnd() { + assertThat(interpreter.resolveELExpression("false && deferred", -1)).isEqualTo(false); + + try { + interpreter.resolveELExpression("foo && deferred && foo_list.add(foo)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("'bar' && deferred && foo_list.add('bar')"); + } + } + + @Test + public void itShortCircuitsDeferredOr() { + assertThat(interpreter.resolveELExpression("foo_list.add(foo) || deferred", -1)) + .isEqualTo(true); + try { + interpreter.resolveELExpression("deferred || foo_list.add(foo)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("deferred || foo_list.add('bar')"); + } + } + + @Test + public void itDoesNotShortCircuitOtherOperators() { + try { + interpreter.resolveELExpression("deferred + range(1)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("deferred + [0]"); + } + } + + @Test + public void itDoesNotShortCircuitNonModification() { + assertThat(interpreter.resolveELExpression("foo_list[0] || deferred", -1)) + .isEqualTo("val"); + try { + interpreter.resolveELExpression("deferred || foo_list[0]", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("deferred || 'val'"); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoiceTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoiceTest.java new file mode 100644 index 000000000..f594a46e9 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstChoiceTest.java @@ -0,0 +1,119 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import java.util.ArrayList; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstChoiceTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter.getContext().put("foo", "foo val"); + interpreter = new JinjavaInterpreter(parentInterpreter); + interpreter.getContext().put("deferred", DeferredValue.instance()); + List fooList = new ArrayList<>(); + fooList.add("val"); + interpreter.getContext().put("foo_list", new PyList(fooList)); + } + + @Test + public void itShortCircuitsChoiceIdentifier() { + try { + interpreter.getContext().put("foo", "foo val"); + interpreter.getContext().put("bar", "bar val"); + interpreter.resolveELExpression( + "deferred ? foo_list.add(foo) : foo_list.add(bar)", + -1 + ); + fail("Should throw deferredParsingException"); + } catch (DeferredParsingException e) { + Assertions + .assertThat(e.getDeferredEvalResult()) + .isEqualTo("deferred ? foo_list.add('foo val') : foo_list.add('bar val')"); + } + } + + @Test + public void itDoesNotShortCircuitsChoiceYes() { + try { + interpreter.getContext().put("bar", "bar val"); + interpreter.resolveELExpression( + "foo_list[0] == 'val' ? deferred : foo_list.add(bar)", + -1 + ); + fail("Should throw deferredParsingException"); + } catch (DeferredParsingException e) { + Assertions.assertThat(e.getDeferredEvalResult()).isEqualTo("deferred"); + } + } + + @Test + public void itDoesNotShortCircuitsChoiceNo() { + try { + interpreter.getContext().put("bar", "bar val"); + interpreter.resolveELExpression( + "foo_list[0] == 'bar' ? foo_list.add(bar) : deferred", + -1 + ); + fail("Should throw deferredParsingException"); + } catch (DeferredParsingException e) { + Assertions.assertThat(e.getDeferredEvalResult()).isEqualTo("deferred"); + } + } + + @Test + public void itResolvesChoiceYes() { + interpreter.getContext().put("bar", "bar val"); + interpreter.resolveELExpression( + "foo_list[0] == 'val' ? foo_list.add(bar) : deferred", + -1 + ); + PyList result = (PyList) interpreter.getContext().get("foo_list"); + assertEquals(result.size(), 2); + assertEquals(result.get(1), "bar val"); + } + + @Test + public void itResolvesChoiceNo() { + interpreter.getContext().put("bar", "bar val"); + interpreter.resolveELExpression( + "foo_list[0] == 'bar' ? deferred : foo_list.add(bar)", + -1 + ); + PyList result = (PyList) interpreter.getContext().get("foo_list"); + assertEquals(result.size(), 2); + assertEquals(result.get(1), "bar val"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java new file mode 100644 index 000000000..6b13a7bc8 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java @@ -0,0 +1,78 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.testobjects.EagerAstDotTestObjects; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstDotTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMethodValidator(BaseJinjavaTest.METHOD_VALIDATOR) + .withReturnTypeValidator(BaseJinjavaTest.RETURN_TYPE_VALIDATOR) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + + interpreter.getContext().put("deferred", DeferredValue.instance()); + } + + @Test + public void itDefersWhenDotThrowsDeferredValueException() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter.getContext().put("foo", new EagerAstDotTestObjects.Foo()); + assertThat(interpreter.render("{{ foo.deferred }}")) + .isEqualTo("{{ foo.deferred }}"); + } + } + + @Test + public void itResolvedDeferredMapWithDot() { + interpreter.getContext().put("foo", new EagerAstDotTestObjects.Foo()); + assertThat(interpreter.render("{{ foo.resolved }}")).isEqualTo("resolved"); + } + + @Test + public void itResolvedNestedDeferredMapWithDot() { + interpreter + .getContext() + .put("foo_map", ImmutableMap.of("bar", new EagerAstDotTestObjects.Foo())); + assertThat(interpreter.render("{{ foo_map.bar.resolved }}")).isEqualTo("resolved"); + } + + @Test + public void itDefersNodeWhenNestedDeferredMapDotThrowsDeferredValueException() { + interpreter + .getContext() + .put("foo_map", ImmutableMap.of("bar", new EagerAstDotTestObjects.Foo())); + assertThat(interpreter.render("{{ foo_map.bar.deferred }}")) + .isEqualTo("{{ foo_map.bar.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifierTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifierTest.java new file mode 100644 index 000000000..2aa6fd09f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstIdentifierTest.java @@ -0,0 +1,40 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.el.JinjavaELContext; +import com.hubspot.jinjava.el.JinjavaInterpreterResolver; +import de.odysseus.el.tree.Bindings; +import java.lang.reflect.Method; +import javax.el.ValueExpression; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstIdentifierTest extends BaseInterpretingTest { + + private JinjavaELContext elContext; + + @Before + public void setup() { + elContext = + new JinjavaELContext(interpreter, new JinjavaInterpreterResolver(interpreter)); + } + + @Test + public void itSavesNullEvalResult() { + EagerAstIdentifier identifier = new EagerAstIdentifier("foo", 0, true); + identifier.eval( + new Bindings( + new Method[] {}, + new ValueExpression[] { + jinjava + .getEagerExpressionFactory() + .createValueExpression(elContext, "#{foo}", Object.class), + } + ), + elContext + ); + assertThat(identifier.hasEvalResult()).isTrue(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethodTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethodTest.java new file mode 100644 index 000000000..d794f6c8e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstMethodTest.java @@ -0,0 +1,241 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.el.ext.DeferredParsingException; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.testobjects.EagerAstDotTestObjects; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstMethodTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMethodValidator(BaseJinjavaTest.METHOD_VALIDATOR) + .withReturnTypeValidator(BaseJinjavaTest.RETURN_TYPE_VALIDATOR) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + + interpreter.getContext().put("deferred", DeferredValue.instance()); + interpreter.getContext().put("foo", "bar"); + List fooList = new ArrayList<>(); + fooList.add("val"); + interpreter.getContext().put("foo_list", new PyList(fooList)); + Map fooMap = new HashMap<>(); + fooMap.put("foo_list", fooList); + interpreter.getContext().put("foo_map", new PyMap(fooMap)); + interpreter.getContext().put("list_name", "foo_list"); + interpreter.getContext().put("map_name", "foo_map"); + Map barMap = new HashMap<>(); + barMap.put("foo_map", interpreter.getContext().get("foo_map")); + interpreter.getContext().put("bar_map", new PyMap(barMap)); + } + + @Test + public void itPreservesIdentifier() { + try { + interpreter.resolveELExpression("foo_list.append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("foo_list.append(deferred)"); + } + } + + @Test + public void itPreservesNonDeferredIdentifier() { + try { + interpreter.resolveELExpression("deferred.modify(foo_map)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()).isEqualTo("deferred.modify(foo_map)"); + } + } + + @Test + public void itPreservesNonDeferredIdentifierWhenSecondParamIsDeferred() { + try { + interpreter.resolveELExpression("foo_list.modify(foo_map, deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("foo_list.modify(foo_map, deferred)"); + } + } + + @Test + public void itPreservesAstDot() { + try { + interpreter.resolveELExpression("foo_map.foo_list.append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("foo_map.foo_list.append(deferred)"); + } + } + + @Test + public void itPreservesDoubleAstDot() { + try { + interpreter.resolveELExpression("bar_map.foo_map.foo_list.append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("bar_map.foo_map.foo_list.append(deferred)"); + } + } + + @Test + public void itPreservesAstBracket() { + try { + interpreter.resolveELExpression("foo_map[list_name].append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("foo_map['foo_list'].append(deferred)"); + } + } + + @Test + public void itPreservesDoubleAstBracket() { + try { + interpreter.resolveELExpression( + "bar_map[map_name][list_name].append(deferred)", + -1 + ); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("bar_map['foo_map']['foo_list'].append(deferred)"); + } + } + + @Test + public void itPreservesAstDotThenAstBracket() { + try { + interpreter.resolveELExpression("bar_map.foo_map[list_name].append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("bar_map.foo_map['foo_list'].append(deferred)"); + } + } + + @Test + public void itPreservesAstBracketThenAstDot() { + try { + interpreter.resolveELExpression("bar_map[map_name].foo_list.append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("bar_map['foo_map'].foo_list.append(deferred)"); + } + } + + @Test + public void itPreservesAstMethod() { + try { + interpreter.resolveELExpression("foo_map.get(list_name).append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("foo_map.get('foo_list').append(deferred)"); + } + } + + @Test + public void itPreservesAstChoice() { + try { + interpreter.resolveELExpression( + "(deferred ? [foo] : foo_list).append(deferred)", + -1 + ); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("(deferred ? ['bar'] : foo_list).append(deferred)"); + } + } + + @Test + public void itPreservesAstList() { + try { + interpreter.resolveELExpression("[foo_list, foo][0].append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + // It's not smart enough to know that it is safe to reduce this to just `foo_list.append(deferred)` + assertThat(e.getDeferredEvalResult()) + .isEqualTo("[foo_list, 'bar'][0].append(deferred)"); + } + } + + @Test + public void itPreservesAstDict() { + try { + interpreter.resolveELExpression( + "{'foo': foo_list, 'bar': foo}.foo.append(deferred)", + -1 + ); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("{'foo': foo_list, 'bar': 'bar'}.foo.append(deferred)"); + } + } + + @Test + public void itPreservesAstTuple() { + try { + interpreter.resolveELExpression("(foo_list, foo)[0].append(deferred)", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + // It's not smart enough to know that it is safe to reduce this to just `foo_list.append(deferred)` + assertThat(e.getDeferredEvalResult()) + .isEqualTo("(foo_list, 'bar')[0].append(deferred)"); + } + } + + @Test + public void itPreservesUnresolvable() { + interpreter.getContext().put("foo_object", new EagerAstDotTestObjects.Foo()); + interpreter.getContext().addMetaContextVariables(Collections.singleton("foo_object")); + try { + interpreter.resolveELExpression("foo_object.deferred|upper", -1); + fail("Should throw DeferredParsingException"); + } catch (DeferredParsingException e) { + assertThat(e.getDeferredEvalResult()) + .isEqualTo("filter:upper.filter(foo_object.deferred, ____int3rpr3t3r____)"); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracketTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracketTest.java new file mode 100644 index 000000000..7ff83ab3b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstRangeBracketTest.java @@ -0,0 +1,47 @@ +package com.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstRangeBracketTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + + interpreter.getContext().put("deferred", DeferredValue.instance()); + } + + @Test + public void itHandlesNullRangePrefix() { + assertThat(interpreter.render("{{ deferred[:100] }}")) + .isEqualTo("{{ deferred[:100] }}"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/ContextTest.java b/src/test/java/com/hubspot/jinjava/interpret/ContextTest.java new file mode 100644 index 000000000..71a310a63 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/ContextTest.java @@ -0,0 +1,126 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import java.util.Collections; +import org.junit.Before; +import org.junit.Test; + +public class ContextTest { + + private static final String RESOLVED_EXPRESSION = "exp"; + private static final String RESOLVED_FUNCTION = "func"; + private static final String RESOLVED_VALUE = "val"; + + private Context context; + + @Before + public void setUp() { + context = new Context(); + } + + @Test + public void itAddsResolvedValuesFromAnotherContextObject() { + Context donor = new Context(); + donor.addResolvedValue(RESOLVED_VALUE); + donor.addResolvedFunction(RESOLVED_FUNCTION); + donor.addResolvedExpression(RESOLVED_EXPRESSION); + + assertThat(context.getResolvedValues()).doesNotContain(RESOLVED_VALUE); + assertThat(context.getResolvedFunctions()).doesNotContain(RESOLVED_FUNCTION); + assertThat(context.getResolvedExpressions()).doesNotContain(RESOLVED_EXPRESSION); + + context.addResolvedFrom(donor); + + assertThat(context.getResolvedValues()).contains(RESOLVED_VALUE); + assertThat(context.getResolvedFunctions()).contains(RESOLVED_FUNCTION); + assertThat(context.getResolvedExpressions()).contains(RESOLVED_EXPRESSION); + } + + @Test + public void itRecursivelyAddsValuesUpTheContextChain() { + Context child = new Context(context); + child.addResolvedValue(RESOLVED_VALUE); + child.addResolvedFunction(RESOLVED_FUNCTION); + child.addResolvedExpression(RESOLVED_EXPRESSION); + + assertThat(context.getResolvedValues()).contains(RESOLVED_VALUE); + assertThat(context.getResolvedFunctions()).contains(RESOLVED_FUNCTION); + assertThat(context.getResolvedExpressions()).contains(RESOLVED_EXPRESSION); + } + + @Test + public void itResetsGlobalContextAfterRender() { + Jinjava jinjava = new Jinjava(); + Context globalContext = jinjava.getGlobalContext(); + + RenderResult result = jinjava.renderForResult( + "{{ foo + 1 }}", + ImmutableMap.of("foo", 1) + ); + + assertThat(result.getOutput()).isEqualTo("2"); + assertThat(result.getContext().getResolvedExpressions()).containsOnly("foo + 1"); + assertThat(result.getContext().getResolvedValues()).containsOnly("foo"); + assertThat(globalContext.getResolvedExpressions()).isEmpty(); + assertThat(globalContext.getResolvedValues()).isEmpty(); + } + + @Test(expected = TemplateSyntaxException.class) + public void itThrowsFromChildContext() throws Exception { + Jinjava jinjava = new Jinjava(); + Context globalContext = jinjava.getGlobalContext(); + globalContext.registerFunction( + new ELFunctionDefinition( + "", + "throw_exception", + this.getClass().getDeclaredMethod("throwException") + ) + ); + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + try { + interpreter.getContext().setThrowInterpreterErrors(true); + interpreter.render( + "{% macro throw() %}{{ throw_exception() }}{% endmacro %}{{ throw() }}" + ); + fail("Did not throw an exception"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itRemovesOtherMetaContextVariables() { + String variableName = "foo"; + EagerExecutionMode.instance().prepareContext(context); + context.addMetaContextVariables(Collections.singleton(variableName)); + assertThat(context.getComputedMetaContextVariables()).contains(variableName); + context.addNonMetaContextVariables(Collections.singleton(variableName)); + assertThat(context.getComputedMetaContextVariables()).doesNotContain(variableName); + context.removeNonMetaContextVariables(Collections.singleton(variableName)); + assertThat(context.getComputedMetaContextVariables()).contains(variableName); + } + + @Test + public void itDoesNotRemoveStaticMetaContextVariables() { + EagerExecutionMode.instance().prepareContext(context); + assertThat(context.getComputedMetaContextVariables()) + .contains(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY); + context.addNonMetaContextVariables( + Collections.singleton(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY) + ); + assertThat(context.getComputedMetaContextVariables()) + .contains(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY); + } + + public static void throwException() { + throw new RuntimeException(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/DeferredTest.java b/src/test/java/com/hubspot/jinjava/interpret/DeferredTest.java new file mode 100644 index 000000000..ceb5c0c1e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/DeferredTest.java @@ -0,0 +1,380 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.util.DeferredValueUtils; +import java.io.IOException; +import java.util.HashMap; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class DeferredTest { + + private JinjavaInterpreter interpreter; + private Jinjava jinjava = new Jinjava(); + Context globalContext = new Context(); + Context localContext; // ref to context created with global as parent + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + globalContext, + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + localContext = interpreter.getContext(); + localContext.put("deferred", DeferredValue.instance()); + localContext.put("resolved", "resolvedValue"); + localContext.put("dict", ImmutableSet.of("a", "b", "c")); + localContext.put("dict2", ImmutableSet.of("e", "f", "g")); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itGetsOriginalValueString() { + assertThat(DeferredValue.instance("abc").toString()).isEqualTo("abc"); + } + + @Test + public void checkAssumptions() { + // Just checking assumptions + String output = interpreter.render("deferred"); + assertThat(output).isEqualTo("deferred"); + + output = interpreter.render("resolved"); + assertThat(output).isEqualTo("resolved"); + + output = interpreter.render("a {{resolved}} b"); + assertThat(output).isEqualTo("a resolvedValue b"); + assertThat(interpreter.getErrors()).isEmpty(); + + assertThat(localContext.getParent()).isEqualTo(globalContext); + } + + @Test + public void itDefersSimpleExpressions() { + String output = interpreter.render("a {{deferred}} b"); + assertThat(output).isEqualTo("a {{deferred}} b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersWholeNestedExpressions() { + String output = interpreter.render("a {{deferred.nested}} b"); + assertThat(output).isEqualTo("a {{deferred.nested}} b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersAsLittleAsPossible() { + String output = interpreter.render("a {{deferred}} {{resolved}} b"); + assertThat(output).isEqualTo("a {{deferred}} resolvedValue b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesIfTag() { + String output = interpreter.render( + "{% if deferred %}{{resolved}}{% else %}b{% endif %}" + ); + assertThat(output).isEqualTo("{% if deferred %}{{resolved}}{% else %}b{% endif %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesNestedIfTag() { + String output = interpreter.render( + "{% if deferred %}{% if resolved %}{{resolved}}{% endif %}{% else %}b{% endif %}" + ); + assertThat(output) + .isEqualTo( + "{% if deferred %}{% if resolved %}{{resolved}}{% endif %}{% else %}b{% endif %}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + /** + * This may or may not be desirable behaviour. + */ + @Test + public void itDoesntPreservesElseIfTag() { + String output = interpreter.render("{% if true %}a{% elif deferred %}b{% endif %}"); + assertThat(output).isEqualTo("a"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolvesIfTagWherePossible() { + String output = interpreter.render("{% if true %}{{deferred}}{% endif %}"); + assertThat(output).isEqualTo("{{deferred}}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolveEqualToInOrCondition() { + String output = interpreter.render( + "{% if 'a' is equalto 'b' or 'a' is equalto 'a' %}{{deferred}}{% endif %}" + ); + assertThat(output).isEqualTo("{{deferred}}"); + } + + @Test + public void itPreserveDeferredVariableResolvingEqualToInOrCondition() { + String inputOutputExpected = + "{% if 'a' is equalto 'b' or 'a' is equalto deferred %}preserved{% endif %}"; + String output = interpreter.render(inputOutputExpected); + + assertThat(output).isEqualTo(inputOutputExpected); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesNotResolveForTagDeferredBlockInside() { + String output = interpreter.render( + "{% for item in dict %} {% if item == deferred %} equal {% else %} not equal {% endif %} {% endfor %}" + ); + assertThat(output) + .isEqualTo( + "{% for item in dict %} {% if item == deferred %} equal {% else %} not equal {% endif %} {% endfor %}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesNotResolveForTagDeferredBlockNestedInside() { + String output = interpreter.render( + "{% for item in dict %} {% if item == 'a' %} equal {% if item == deferred %} {% endif %} {% else %} not equal {% endif %} {% endfor %}" + ); + assertThat(output) + .isEqualTo( + "{% for item in dict %} {% if item == 'a' %} equal {% if item == deferred %} {% endif %} {% else %} not equal {% endif %} {% endfor %}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesNotResolveNestedForTags() { + String output = interpreter.render( + "{% for item in dict %} {% for item2 in dict2 %} {% if item2 == 'e' %} equal {% if item2 == deferred %} {% endif %} {% else %} not equal {% endif %} {% endfor %} {% endfor %}" + ); + assertThat(output) + .isEqualTo( + "{% for item in dict %} {% for item2 in dict2 %} {% if item2 == 'e' %} equal {% if item2 == deferred %} {% endif %} {% else %} not equal {% endif %} {% endfor %} {% endfor %}" + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesNestedExpressions() { + localContext.put("nested", "some {{deferred}} value"); + String output = interpreter.render("Test {{nested}}"); + assertThat(output).isEqualTo("Test some {{deferred}} value"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesForTag() { + String output = interpreter.render( + "{% for item in deferred %}{{item.name}}{% else %}last{% endfor %}" + ); + assertThat(output) + .isEqualTo("{% for item in deferred %}{{item.name}}{% else %}last{% endfor %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesFilters() { + String output = interpreter.render("{{ deferred|capitalize }}"); + assertThat(output).isEqualTo("{{ deferred|capitalize }}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesFunctions() { + String output = interpreter.render("{{ deferred|datetimeformat('%B %e, %Y') }}"); + assertThat(output).isEqualTo("{{ deferred|datetimeformat('%B %e, %Y') }}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itPreservesRandomness() { + String output = interpreter.render("{{ [1,2,3]|shuffle }}"); + assertThat(output).isEqualTo("{{ [1,2,3]|shuffle }}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersMacro() { + localContext.put("padding", 0); + localContext.put("added_padding", 10); + String deferredOutput = interpreter.render( + getFixtureTemplate("deferred-macro.jinja") + ); + Object padding = localContext.get("padding"); + assertThat(padding).isInstanceOf(DeferredValue.class); + assertThat(((DeferredValue) padding).getOriginalValue()).isEqualTo(10); + + localContext.put("padding", ((DeferredValue) padding).getOriginalValue()); + localContext.put("added_padding", 10); + // not deferred anymore + localContext.put("deferred", 5); + localContext.getGlobalMacro("inc_padding").setDeferred(false); + + String output = interpreter.render(deferredOutput); + assertThat(output.replace("\n", "")).isEqualTo("0,10,15,25"); + } + + @Test + public void itDefersAllVariablesUsedInDeferredNode() { + String template = getFixtureTemplate("vars-in-deferred-node.jinja"); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + Object varInScope = localContext.get("varUsedInForScope"); + assertThat(varInScope).isInstanceOf(DeferredValue.class); + DeferredValue varInScopeDeferred = (DeferredValue) varInScope; + assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("outside if statement"); + + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender).isEqualTo("outside if statement entered if statement"); + + localContext.put("deferred", DeferredValue.instance()); + localContext.put("resolved", "resolvedValue"); + } + + @Test + public void itDefersDependantVariables() { + String template = ""; + template += + "{% set resolved_variable = 'resolved' %} {% set deferred_variable = deferred + '-' + resolved_variable %}"; + template += "{{ deferred_variable }}"; + interpreter.render(template); + localContext.get("resolved_variable"); + } + + @Test + public void itDefersVariablesComparedAgainstDeferredVals() { + String template = ""; + template += "{% set testVar = 'testvalue' %}"; + template += "{% if deferred == testVar %} true {% else %} false {% endif %}"; + + interpreter.render(template); + Object varInScope = localContext.get("testVar"); + assertThat(varInScope).isInstanceOf(DeferredValue.class); + DeferredValue varInScopeDeferred = (DeferredValue) varInScope; + assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("testvalue"); + } + + @Test + public void itDoesNotPutDeferredVariablesOnGlobalContext() { + String template = getFixtureTemplate("set-within-lower-scope.jinja"); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + interpreter.render(template); + assertThat(globalContext).isEmpty(); + } + + @Test + public void itPutsDeferredVariablesOnParentScopes() { + String template = getFixtureTemplate("set-within-lower-scope.jinja"); + localContext.put("deferredValue", DeferredValue.instance("resolved")); + interpreter.render(template); + assertThat(localContext).containsKey("varSetInside"); + Object varSetInside = localContext.get("varSetInside"); + assertThat(varSetInside).isInstanceOf(DeferredValue.class); + DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside; + assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope"); + } + + @Test + public void puttingDeferredVariablesOnParentScopesDoesNotBreakSetTag() { + String template = getFixtureTemplate("set-within-lower-scope-twice.jinja"); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + assertThat(localContext).containsKey("varSetInside"); + Object varSetInside = localContext.get("varSetInside"); + assertThat(varSetInside).isInstanceOf(DeferredValue.class); + DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside; + assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope"); + + HashMap deferredContext = + DeferredValueUtils.getDeferredContextWithOriginalValues(localContext); + deferredContext.forEach(localContext::put); + String secondRender = interpreter.render(output); + assertThat(secondRender.trim()) + .isEqualTo("inside first scopeinside first scope2".trim()); + } + + @Test + public void itMarksVariablesSetInDeferredBlockAsDeferred() { + String template = getFixtureTemplate("set-in-deferred.jinja"); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + String output = interpreter.render(template); + Context context = localContext; + assertThat(localContext).containsKey("varSetInside"); + Object varSetInside = localContext.get("varSetInside"); + assertThat(varSetInside).isInstanceOf(DeferredValue.class); + assertThat(output).contains("{{ varSetInside }}"); + assertThat(context.get("a")).isInstanceOf(DeferredValue.class); + assertThat(context.get("b")).isInstanceOf(DeferredValue.class); + assertThat(context.get("c")).isInstanceOf(DeferredValue.class); + } + + @Test + public void itMarksVariablesUsedAsMapKeysAsDeferred() { + String template = getFixtureTemplate("deferred-map-access.jinja"); + + localContext.put("deferredValue", DeferredValue.instance("resolved")); + localContext.put("deferredValue2", DeferredValue.instance("key")); + ImmutableMap> map = ImmutableMap.of( + "map", + ImmutableMap.of("key", "value") + ); + localContext.put("imported", map); + + String output = interpreter.render(template); + assertThat(localContext).containsKey("deferredValue2"); + Object deferredValue2 = localContext.get("deferredValue2"); + localContext + .getDeferredNodes() + .forEach(node -> + DeferredValueUtils.findAndMarkDeferredProperties(localContext, node) + ); + assertThat(deferredValue2).isInstanceOf(DeferredValue.class); + assertThat(output) + .contains("{% set varSetInside = imported.map[deferredValue2.nonexistentprop] %}"); + } + + private String getFixtureTemplate(String templateLocation) { + try { + return Resources.toString( + Resources.getResource("deferred/" + templateLocation), + Charsets.UTF_8 + ); + } catch (IOException e) { + return null; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/JinjavaInterpreterTest.java b/src/test/java/com/hubspot/jinjava/interpret/JinjavaInterpreterTest.java index 063b37b6e..b6f1524fe 100644 --- a/src/test/java/com/hubspot/jinjava/interpret/JinjavaInterpreterTest.java +++ b/src/test/java/com/hubspot/jinjava/interpret/JinjavaInterpreterTest.java @@ -1,98 +1,646 @@ package com.hubspot.jinjava.interpret; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import org.junit.Before; -import org.junit.Test; - +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.mode.PreserveRawExecutionMode; +import com.hubspot.jinjava.objects.date.FormattedDate; +import com.hubspot.jinjava.objects.date.StrftimeFormatter; +import com.hubspot.jinjava.testobjects.JinjavaInterpreterTestObjects; import com.hubspot.jinjava.tree.TextNode; +import com.hubspot.jinjava.tree.output.BlockInfo; +import com.hubspot.jinjava.tree.output.OutputList; import com.hubspot.jinjava.tree.parse.TextToken; - +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Locale; +import java.util.Optional; +import org.junit.Before; +import org.junit.Test; public class JinjavaInterpreterTest { - Jinjava jinjava; - JinjavaInterpreter interpreter; + private Jinjava jinjava; + private JinjavaInterpreter interpreter; + private TokenScannerSymbols symbols; @Before public void setup() { - jinjava = new Jinjava(); + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTimeZone(ZoneId.of("America/New_York")) + .build() + ); interpreter = jinjava.newInterpreter(); + symbols = interpreter.getConfig().getTokenScannerSymbols(); } @Test public void resolveBlockStubsWithNoStubs() { - assertThat(interpreter.resolveBlockStubs("foo")).isEqualTo("foo"); + assertThat(interpreter.render("foo")).isEqualTo("foo"); } @Test public void resolveBlockStubsWithMissingNamedBlock() { - String content = String.format("this is %sfoobar%s!", JinjavaInterpreter.BLOCK_STUB_START, JinjavaInterpreter.BLOCK_STUB_END); - assertThat(interpreter.resolveBlockStubs(content)).isEqualTo("this is !"); + String content = "this is {% block foobar %}{% endblock %}!"; + assertThat(interpreter.render(content)).isEqualTo("this is !"); + } + + @Test + public void resolveBlockStubs() { + interpreter.addBlock( + "foobar", + new BlockInfo( + Lists.newLinkedList( + Lists.newArrayList((new TextNode(new TextToken("sparta", -1, -1, symbols)))) + ), + Optional.empty(), + 0, + 0 + ) + ); + String content = "this is {% block foobar %}foobar{% endblock %}!"; + assertThat(interpreter.render(content)).isEqualTo("this is sparta!"); } @Test - public void resolveBlockStubs() throws Exception { - interpreter.addBlock("foobar", Lists.newLinkedList(Lists.newArrayList((new TextNode(new TextToken("sparta", -1)))))); - String content = String.format("this is %sfoobar%s!", JinjavaInterpreter.BLOCK_STUB_START, JinjavaInterpreter.BLOCK_STUB_END); - assertThat(interpreter.resolveBlockStubs(content)).isEqualTo("this is sparta!"); + public void resolveBlockStubsWithSpecialChars() { + interpreter.addBlock( + "foobar", + new BlockInfo( + Lists.newLinkedList( + Lists.newArrayList(new TextNode(new TextToken("$150.00", -1, -1, symbols))) + ), + Optional.empty(), + 0, + 0 + ) + ); + String content = "this is {% block foobar %}foobar{% endblock %}!"; + assertThat(interpreter.render(content)).isEqualTo("this is $150.00!"); } @Test - public void resolveBlockStubsWithSpecialChars() throws Exception { - interpreter.addBlock("foobar", Lists.newLinkedList(Lists.newArrayList(new TextNode(new TextToken("$150.00", -1))))); - String content = String.format("this is %sfoobar%s!", JinjavaInterpreter.BLOCK_STUB_START, JinjavaInterpreter.BLOCK_STUB_END); - assertThat(interpreter.resolveBlockStubs(content)).isEqualTo("this is $150.00!"); + public void resolveBlockStubsWithCycle() { + String content = interpreter.render( + "{% block foo %}{% block foo %}{% endblock %}{% endblock %}" + ); + assertThat(content).isEmpty(); } // Ex VariableChain stuff - static class Foo { - private String bar; + @Test + public void singleWordProperty() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter.resolveProperty(new JinjavaInterpreterTestObjects.Foo("a"), "bar") + ) + .isEqualTo("a"); + } + } - public Foo(String bar) { - this.bar = bar; + @Test + public void multiWordCamelCase() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter.resolveProperty(new JinjavaInterpreterTestObjects.Foo("a"), "barFoo") + ) + .isEqualTo("a"); } + } - public String getBar() { - return bar; + @Test + public void multiWordSnakeCase() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter.resolveProperty(new JinjavaInterpreterTestObjects.Foo("a"), "bar_foo") + ) + .isEqualTo("a"); } + } - public String getBarFoo() { - return bar; + @Test + public void multiWordNumberSnakeCase() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter.resolveProperty( + new JinjavaInterpreterTestObjects.Foo("a"), + "bar_foo_1" + ) + ) + .isEqualTo("a"); } + } - public String getBarFoo1() { - return bar; + @Test + public void jsonIgnore() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter.resolveProperty( + new JinjavaInterpreterTestObjects.Foo("a"), + "barHidden" + ) + ) + .isEqualTo("a"); } } @Test - public void singleWordProperty() { - assertThat(interpreter.resolveProperty(new Foo("a"), "bar")).isEqualTo("a"); + public void triesBeanMethodFirst() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat( + interpreter + .resolveProperty(ZonedDateTime.parse("2013-09-19T12:12:12+00:00"), "year") + .toString() + ) + .isEqualTo("2013"); + } } @Test - public void multiWordCamelCase() { - assertThat(interpreter.resolveProperty(new Foo("a"), "barFoo")).isEqualTo("a"); + public void enterScopeTryFinally() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter.getContext().put("foo", "parent"); + + interpreter.enterScope(); + try { + interpreter.getContext().put("foo", "child"); + assertThat(interpreter.resolveELExpression("foo", 1)).isEqualTo("child"); + } finally { + interpreter.leaveScope(); + } + + assertThat(interpreter.resolveELExpression("foo", 1)).isEqualTo("parent"); + } } @Test - public void multiWordSnakeCase() { - assertThat(interpreter.resolveProperty(new Foo("a"), "bar_foo")).isEqualTo("a"); + public void enterScopeTryWithResources() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter.getContext().put("foo", "parent"); + + try (InterpreterScopeClosable c = interpreter.enterScope()) { + interpreter.getContext().put("foo", "child"); + assertThat(interpreter.resolveELExpression("foo", 1)).isEqualTo("child"); + } + + assertThat(interpreter.resolveELExpression("foo", 1)).isEqualTo("parent"); + } } @Test - public void multiWordNumberSnakeCase() { - assertThat(interpreter.resolveProperty(new Foo("a"), "bar_foo_1")).isEqualTo("a"); + public void bubbleUpDependenciesFromLowerScope() { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + String dependencyType = "foo"; + String dependencyIdentifier = "123"; + + interpreter.enterScope(); + interpreter.getContext().addDependency(dependencyType, dependencyIdentifier); + assertThat(interpreter.getContext().getDependencies().get(dependencyType)) + .contains(dependencyIdentifier); + interpreter.leaveScope(); + + assertThat(interpreter.getContext().getDependencies().get(dependencyType)) + .contains(dependencyIdentifier); + } } @Test - public void triesBeanMethodFirst() { - assertThat(interpreter.resolveProperty(ZonedDateTime.parse("2013-09-19T12:12:12+00:00"), "year").toString()).isEqualTo("2013"); + public void parseWithSyntaxError() { + RenderResult result = new Jinjava().renderForResult("{%}", new HashMap<>()); + assertThat(result.getErrors()).isNotEmpty(); + assertThat(result.getErrors().get(0).getReason()).isEqualTo(ErrorReason.SYNTAX_ERROR); } + @Test + public void itLimitsOutputSize() { + JinjavaConfig outputSizeLimitedConfig = BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(20) + .build(); + String output = "123456789012345678901234567890"; + + RenderResult renderResult = new Jinjava().renderForResult(output, new HashMap<>()); + assertThat(renderResult.getOutput()).isEqualTo(output); + assertThat(renderResult.hasErrors()).isFalse(); + + renderResult = + new Jinjava(outputSizeLimitedConfig).renderForResult(output, new HashMap<>()); + assertThat(renderResult.getErrors().get(0).getMessage()) + .contains("OutputTooBigException"); + } + + @Test + public void itLimitsOutputSizeOnTagNode() { + JinjavaConfig outputSizeLimitedConfig = BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(10) + .build(); + String output = "{% for i in range(20) %} {{ i }} {% endfor %}"; + + RenderResult renderResult = new Jinjava().renderForResult(output, new HashMap<>()); + assertThat(renderResult.getOutput()) + .isEqualTo( + " 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 " + ); + assertThat(renderResult.hasErrors()).isFalse(); + + renderResult = + new Jinjava(outputSizeLimitedConfig).renderForResult(output, new HashMap<>()); + assertThat(renderResult.getErrors().get(0).getMessage()) + .contains("OutputTooBigException"); + + assertThat(renderResult.getOutput()).isEqualTo(" 0 1 2 "); + } + + @Test + public void itLimitsOutputSizeWhenSumOfNodeSizesExceedsMax() { + JinjavaConfig outputSizeLimitedConfig = BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(19) + .build(); + String input = "1234567890{% block testchild %}1234567890{% endblock %}"; + String output = "12345678901234567890"; // Note that this exceeds the max size + + RenderResult renderResult = new Jinjava().renderForResult(input, new HashMap<>()); + assertThat(renderResult.getOutput()).isEqualTo(output); + assertThat(renderResult.hasErrors()).isFalse(); + + renderResult = + new Jinjava(outputSizeLimitedConfig).renderForResult(input, new HashMap<>()); + assertThat(renderResult.hasErrors()).isTrue(); + assertThat(renderResult.getErrors().get(0).getMessage()) + .contains("OutputTooBigException"); + } + + @Test + public void itCanPreserveRawTags() { + JinjavaConfig preserveConfig = BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build(); + String input = "1{% raw %}2{% endraw %}3"; + String normalOutput = "123"; + String preservedOutput = "1{% raw %}2{% endraw %}3"; + + RenderResult renderResult = new Jinjava().renderForResult(input, new HashMap<>()); + assertThat(renderResult.getOutput()).isEqualTo(normalOutput); + assertThat(renderResult.hasErrors()).isFalse(); + + renderResult = new Jinjava(preserveConfig).renderForResult(input, new HashMap<>()); + assertThat(renderResult.getOutput()).isEqualTo(preservedOutput); + assertThat(renderResult.hasErrors()).isFalse(); + } + + @Test + public void itKnowsThatMethodIsResolved() { + // Tests fix of bug where an error when an AstMethod is called would cause an error to be output + // saying the method could not be resolved. + String input = + "{% set a, b = {}, [] %}{% macro a.foo()%} 1-{{ b.bar() }}. {% endmacro %} {{ a.foo() }}"; + + RenderResult renderResult = new Jinjava() + .renderForResult(input, ImmutableMap.of("deferred", DeferredValue.instance())); + assertThat(renderResult.getOutput().trim()).isEqualTo("1-."); + // Does not contain an error about 'a.foo()' being unknown. + assertThat(renderResult.getErrors()).hasSize(1); + } + + @Test + public void itThrowsFatalErrors() { + interpreter.getContext().setThrowInterpreterErrors(true); + assertThatThrownBy(() -> + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.UNKNOWN, + ErrorItem.PROPERTY, + "", + "", + interpreter.getLineNumber(), + interpreter.getPosition(), + new RuntimeException() + ) + ) + ) + .isInstanceOf(TemplateSyntaxException.class); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itHidesWarningErrors() { + interpreter.getContext().setThrowInterpreterErrors(true); + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.UNKNOWN, + ErrorItem.PROPERTY, + "", + "", + interpreter.getLineNumber(), + interpreter.getPosition(), + new RuntimeException() + ) + ); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itBindsUnaryMinusTighterThanCmp() { + assertThat(interpreter.render("{{ (-5 > 4) }}")).isEqualTo("false"); + } + + @Test + public void itInterpretsFilterChainsInOrder() { + assertThat(interpreter.render("{{ 'foo' | upper | replace('O', 'A') }}")) + .isEqualTo("FAA"); + } + + @Test + public void itInterpretsWhitespaceControl() { + assertThat(interpreter.render(". {%- set x = 5 -%} .")).isEqualTo(".."); + } + + @Test + public void itInterpretsEmptyExpressions() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTimeZone(ZoneId.of("America/New_York")) + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withParseWhitespaceControlStrictly(false) + .build() + ) + .build() + ); + interpreter = jinjava.newInterpreter(); + assertThat(interpreter.render("{{}}")).isEqualTo(""); + } + + @Test + public void itInterpretsFormattedDates() { + String result = jinjava.render( + "{{ d }}", + ImmutableMap.of( + "d", + new FormattedDate( + "medium", + "en-US", + ZonedDateTime.of(2022, 10, 20, 17, 9, 43, 0, ZoneId.of("America/New_York")) + ) + ) + ); + + assertThat(result).isIn("Oct 20, 2022, 5:09:43 PM", "Oct 20, 2022, 5:09:43 PM"); + } + + @Test + public void itHandlesInvalidFormatInFormattedDate() { + RenderResult result = jinjava.renderForResult( + "{{ d }}", + ImmutableMap.of( + "d", + new FormattedDate( + "not a real format", + "en_US", + ZonedDateTime.of(2022, 10, 20, 17, 9, 43, 0, ZoneId.of("America/New_York")) + ) + ) + ); + + assertThat(result.getErrors()) + .extracting(TemplateError::getMessage) + .containsOnly("Invalid date format 'not a real format'"); + } + + @Test + public void itDefaultsToMediumOnEmptyFormatInFormattedDate() { + ZonedDateTime date = ZonedDateTime.of( + 2022, + 10, + 20, + 17, + 9, + 43, + 0, + ZoneId.of("America/New_York") + ); + String result = jinjava.render( + "{{ d }}", + ImmutableMap.of("d", new FormattedDate("", "en_US", date)) + ); + + assertThat(result) + .isEqualTo( + StrftimeFormatter.format(date, "medium", Locale.forLanguageTag("en-US")) + ); + } + + @Test + public void itHandlesInvalidLocaleInFormattedDate() { + RenderResult result = jinjava.renderForResult( + "{{ d }}", + ImmutableMap.of( + "d", + new FormattedDate( + "medium", + "not a real locale", + ZonedDateTime.of(2022, 10, 20, 17, 9, 43, 0, ZoneId.of("America/New_York")) + ) + ) + ); + + assertThat(result.getErrors()) + .extracting(TemplateError::getMessage) + .containsOnly("Invalid locale format: not a real locale"); + } + + @Test + public void itDefaultsToUnitedStatesOnEmptyLocaleInFormattedDate() { + ZonedDateTime date = ZonedDateTime.of( + 2022, + 10, + 20, + 17, + 9, + 43, + 0, + ZoneId.of("America/New_York") + ); + String result = jinjava.render( + "{{ d }}", + ImmutableMap.of("d", new FormattedDate("medium", "", date)) + ); + + assertThat(result) + .isEqualTo( + StrftimeFormatter.format(date, "medium", Locale.forLanguageTag("en-US")) + ); + } + + @Test + public void itFiltersDuplicateErrors() { + TemplateError error1 = new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.OTHER, + TemplateError.ErrorItem.FILTER, + "the first error", + "list", + interpreter.getLineNumber(), + interpreter.getPosition(), + null + ); + + TemplateError copiedError1 = new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.OTHER, + TemplateError.ErrorItem.FILTER, + "the first error", + "list", + interpreter.getLineNumber(), + interpreter.getPosition(), + null + ); + + TemplateError error2 = new TemplateError( + TemplateError.ErrorType.WARNING, + TemplateError.ErrorReason.OTHER, + TemplateError.ErrorItem.FILTER, + "the second error", + "list", + interpreter.getLineNumber(), + interpreter.getPosition(), + null + ); + + interpreter.addError(error1); + interpreter.addError(error2); + interpreter.addError(copiedError1); + + assertThat(interpreter.getErrors()).containsExactly(error1, error2); + } + + @Test + public void itPreventsAccidentalExpressions() { + String makeExpression = "if (true) {\n{%- print deferred -%}\n}"; + String makeTag = "if (true) {\n{%- print '% print 123 %' -%}\n}"; + String makeNote = "if (true) {\n{%- print '# note #' -%}\n}"; + jinjava.getGlobalContext().put("deferred", DeferredValue.instance()); + + JinjavaInterpreter normalInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContext(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + JinjavaInterpreter preventingInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContext(), + BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add(OutputList.PREVENT_ACCIDENTAL_EXPRESSIONS, FeatureStrategies.ACTIVE) + .build() + ) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + JinjavaInterpreter.pushCurrent(normalInterpreter); + try { + assertThat(normalInterpreter.render(makeExpression)) + .isEqualTo("if (true) {{% print deferred %}}"); + assertThat(normalInterpreter.render(makeTag)) + .isEqualTo("if (true) {% print 123 %}"); + assertThat(normalInterpreter.render(makeNote)).isEqualTo("if (true) {# note #}"); + } finally { + JinjavaInterpreter.popCurrent(); + } + JinjavaInterpreter.pushCurrent(preventingInterpreter); + try { + assertThat(preventingInterpreter.render(makeExpression)) + .isEqualTo("if (true) {\n" + "{#- #}{% print deferred %}}"); + assertThat(preventingInterpreter.render(makeTag)) + .isEqualTo("if (true) {\n" + "{#- #}% print 123 %}"); + assertThat(preventingInterpreter.render(makeNote)) + .isEqualTo("if (true) {\n" + "{#- #}# note #}"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itOutputsUndefinedVariableError() { + String template = "{% set foo=123 %}{{ foo }}{{ bar }}"; + + JinjavaInterpreter normalInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContext(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + JinjavaInterpreter outputtingErrorInterpreters = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContext(), + BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + JinjavaInterpreter.OUTPUT_UNDEFINED_VARIABLES_ERROR, + FeatureStrategies.ACTIVE + ) + .build() + ) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + + String normalRenderResult = normalInterpreter.render(template); + String outputtingErrorRenderResult = outputtingErrorInterpreters.render(template); + assertThat(normalRenderResult).isEqualTo("123"); + assertThat(outputtingErrorRenderResult).isEqualTo("123"); + assertThat(normalInterpreter.getErrors()).isEmpty(); + assertThat(outputtingErrorInterpreters.getErrors().size()).isEqualTo(1); + assertThat(outputtingErrorInterpreters.getErrors().get(0).getMessage()) + .contains("Undefined variable: 'bar'"); + assertThat(outputtingErrorInterpreters.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.UNKNOWN); + assertThat(outputtingErrorInterpreters.getErrors().get(0).getSeverity()) + .isEqualTo(ErrorType.WARNING); + assertThat(outputtingErrorInterpreters.getErrors().get(0).getCategoryErrors()) + .isEqualTo(ImmutableMap.of("variable", "bar")); + } + + @Test + public void itDoesNotAllowAccessingPropertiesOfInterpreter() { + assertThat(jinjava.render("{{ null.config }}", new HashMap<>())).isEqualTo(""); + } } diff --git a/src/test/java/com/hubspot/jinjava/interpret/LazyExpressionTest.java b/src/test/java/com/hubspot/jinjava/interpret/LazyExpressionTest.java new file mode 100644 index 000000000..10afc454c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/LazyExpressionTest.java @@ -0,0 +1,62 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.interpret.LazyExpression.Memoization; +import java.util.List; +import org.junit.Test; + +public class LazyExpressionTest { + + @Test + public void itSerializesUnderlyingValue() throws JsonProcessingException { + LazyExpression expression = LazyExpression.of( + () -> ImmutableMap.of("test", "hello", "test2", "hello2"), + "{}" + ); + Object evaluated = expression.get(); + assertThat(evaluated).isNotNull(); + assertThat(new ObjectMapper().writeValueAsString(expression)) + .isEqualTo("{\"test\":\"hello\",\"test2\":\"hello2\"}"); + } + + @Test + public void itSerializesNonEvaluatedValueToEmpty() throws JsonProcessingException { + LazyExpression expression = LazyExpression.of( + () -> ImmutableMap.of("test", "hello", "test2", "hello2"), + "{}" + ); + assertThat(new ObjectMapper().writeValueAsString(expression)).isEqualTo("\"\""); + } + + @Test + public void itMemoizesByDefault() { + List mock = mock(List.class); + LazyExpression expression = LazyExpression.of(mock::isEmpty, ""); + expression.get(); + expression.get(); + verify(mock).isEmpty(); + } + + @Test + public void itAllowsMemoizationToBeDisabled() { + List mock = mock(List.class); + LazyExpression expression = LazyExpression.of(mock::isEmpty, "", Memoization.OFF); + expression.get(); + expression.get(); + verify(mock, times(2)).isEmpty(); + } + + @Test + public void itAllowsMemoizationToBeEnabled() { + List mock = mock(List.class); + LazyExpression expression = LazyExpression.of(mock::isEmpty, "", Memoization.ON); + expression.get(); + expression.get(); + verify(mock).isEmpty(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java b/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java new file mode 100644 index 000000000..20c4729e4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java @@ -0,0 +1,75 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class LegacyOperatorPrecedenceTest { + + Jinjava legacy; + Jinjava modern; + + @Before + public void setUp() throws Exception { + legacy = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withLegacyOverrides(LegacyOverrides.NONE) + .build() + ); + modern = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUseNaturalOperatorPrecedence(true).build() + ) + .build() + ); + } + + @Test + public void itBindsUnaryMinusTighterThanIs() { + String template = "{{ (-5 is integer) }}"; + + assertThatExceptionOfType(FatalTemplateErrorsException.class) + .isThrownBy(() -> legacy.render(template, new HashMap<>())) + .withMessage("Cannot negate 'class java.lang.Boolean'"); + assertThat(modern.render(template, new HashMap<>())).isEqualTo("true"); + } + + @Test + public void itBindsUnaryMinusTighterThanIsNot() { + String template = "{{ (-5 is not integer) }}"; + + assertThatExceptionOfType(FatalTemplateErrorsException.class) + .isThrownBy(() -> legacy.render(template, new HashMap<>())) + .withMessage("Cannot negate 'class java.lang.Boolean'"); + assertThat(modern.render(template, new HashMap<>())).isEqualTo("false"); + } + + @Test + public void itBindsUnaryMinusTighterThanFilters() { + String template = "{{ (-5 | abs) }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("-5"); + assertThat(modern.render(template, new HashMap<>())).isEqualTo("5"); + } + + @Test + public void itBindsFiltersTighterThanMul() { + String template = "{{ (-5 * -4 | abs) }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("20"); + assertThat(modern.render(template, new HashMap<>())).isEqualTo("-20"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java b/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java new file mode 100644 index 000000000..c9cfdc8ce --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java @@ -0,0 +1,94 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class LegacyWhitespaceControlParsingTest { + + Jinjava legacy; + Jinjava modern; + + @Before + public void setUp() throws Exception { + legacy = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withLegacyOverrides(LegacyOverrides.NONE) + .build() + ); + modern = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withParseWhitespaceControlStrictly(true).build() + ) + .build() + ); + } + + @Test + public void itInterpretsStandaloneNegatives() { + String template = "{{ -10 }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("10"); + assertThat(modern.render(template, new HashMap<>())).isEqualTo("-10"); + } + + @Test + public void itInterpretsStandaloneNegativesWithWhitespace() { + String template = "{{ - 10 }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("10"); + + // Somewhat surprising, but this aligns with Jinja + assertThat(modern.render(template, new HashMap<>())).isEqualTo("-10"); + } + + @Test + public void itErrorsOnTrailingDash() { + String template = "{{ 10- }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("10"); + assertThatExceptionOfType(FatalTemplateErrorsException.class) + .isThrownBy(() -> modern.render(template, new HashMap<>())) + .withMessageContaining("syntax error at position 6"); + } + + @Test + public void itErrorsOnSpacedTrailingDash() { + String template = "{{ 10 - }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("10"); + assertThatExceptionOfType(FatalTemplateErrorsException.class) + .isThrownBy(() -> modern.render(template, new HashMap<>())) + .withMessageContaining("syntax error at position 7"); + } + + @Test + public void itErrorsOnSpacedDashesOnBothSides() { + String template = "{{ - 10 - }}"; + + assertThat(legacy.render(template, new HashMap<>())).isEqualTo("10"); + assertThatExceptionOfType(FatalTemplateErrorsException.class) + .isThrownBy(() -> modern.render(template, new HashMap<>())) + .withMessageContaining("syntax error at position 9"); + } + + @Test + public void itHandlesEmptyExpressionToken() { + String template = "{{}}"; + + assertThat(modern.render(template, new HashMap<>())).isEqualTo(""); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/NullValueTest.java b/src/test/java/com/hubspot/jinjava/interpret/NullValueTest.java new file mode 100644 index 000000000..ab484ce60 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/NullValueTest.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; + +public class NullValueTest { + + @Test + public void itSerializesUnderlyingValue() throws JsonProcessingException { + LazyExpression expression = LazyExpression.of( + () -> ImmutableMap.of("test", "hello", "test2", NullValue.instance()), + "{}" + ); + Object evaluated = expression.get(); + assertThat(evaluated).isNotNull(); + assertThat(new ObjectMapper().writeValueAsString(expression)) + .isEqualTo("{\"test\":\"hello\",\"test2\":null}"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/PartiallyDeferredValueTest.java b/src/test/java/com/hubspot/jinjava/interpret/PartiallyDeferredValueTest.java new file mode 100644 index 000000000..55a86027c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/PartiallyDeferredValueTest.java @@ -0,0 +1,193 @@ +package com.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.testobjects.PartiallyDeferredValueTestObjects; +import org.junit.Before; +import org.junit.Test; + +public class PartiallyDeferredValueTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withMaxMacroRecursionDepth(5) + .withEnableRecursiveMacroCalls(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + + interpreter.getContext().put("deferred", DeferredValue.instance()); + } + + @Test + public void itDefersNodeWhenCannotSerializePartiallyDeferredValue() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.BadSerialization()); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.deferred }}")) + .isEqualTo("{{ bar.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itDefersNodeWhenCannotCallPartiallyDeferredMapEntrySet() { + interpreter + .getContext() + .put( + "foo", + new PartiallyDeferredValueTestObjects.BadEntrySet( + ImmutableMap.of("resolved", "resolved") + ) + ); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.deferred }}")) + .isEqualTo("{{ bar.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itDefersNodeWhenPyishSerializationFails() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.BadPyishSerializable()); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.deferred }}")) + .isEqualTo("{{ bar.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itSerializesWhenPyishSerializationIsGood() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.GoodPyishSerializable()); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.deferred }}")) + .isEqualTo("{{ good.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + @Test + public void itSerializesWhenEntrySetIsBadButItIsPyishSerializable() { + interpreter + .getContext() + .put( + "foo", + new PartiallyDeferredValueTestObjects.BadEntrySetButPyishSerializable( + ImmutableMap.of("resolved", "resolved") + ) + ); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo %}{{ bar.deferred }}")) + .isEqualTo("{{ hello.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + @Test + public void itSerializesPartiallyDeferredValueIsInsideAMap() { + interpreter + .getContext() + .put( + "foo_map", + new PyMap( + ImmutableMap.of( + "foo", + new PartiallyDeferredValueTestObjects.GoodPyishSerializable() + ) + ) + ); + assertThat(interpreter.render("{% set bar = foo_map %}{{ bar.foo.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo_map.foo %}{{ bar.resolved }}")) + .isEqualTo("resolved"); + assertThat(interpreter.render("{% set bar = foo_map %}{{ bar.foo.deferred }}")) + .isEqualTo("{{ good.deferred }}"); + assertThat(interpreter.render("{% set bar = foo_map.foo %}{{ bar.deferred }}")) + .isEqualTo("{{ good.deferred }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + @Test + public void itSerializesPartiallyDeferredValueIsPutInsideAMap() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.GoodPyishSerializable()); + assertThat( + interpreter.render("{% set bar = {'my_key': foo} %}{% print bar.my_key.resolved %}") + ) + .isEqualTo("resolved"); + assertThat( + interpreter.render("{% set bar = {'my_key': foo} %}{% print bar.my_key.deferred %}") + ) + .isEqualTo("{% print good.deferred %}"); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + @Test + public void itSerializesPartiallyDeferredValueIsPutInsideAMapInComplexExpression() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.GoodPyishSerializable()); + assertThat( + interpreter.render( + "{% set bar = {'my_key': foo} %}{% print (1 + 1 == 3 || bar.my_key.resolved) ~ '.' %}" + ) + ) + .isEqualTo("resolved."); + assertThat( + interpreter.render( + "{% set bar = {'my_key': foo} %}{% print (1 + 1 == 3 || bar.my_key.deferred) ~ '.' %}" + ) + ) + .isEqualTo("{% print (false || good.deferred) ~ '.' %}"); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + @Test + public void itSerializesPartiallyDeferredValueInsteadOfPreservingOriginalIdentifier() { + interpreter + .getContext() + .put("foo", new PartiallyDeferredValueTestObjects.GoodPyishSerializable()); + assertThat( + interpreter.render( + "{% set list = [] %}{% set bar = foo %}{% do list.append(bar['resolved']) %}{% print list %}" + ) + ) + .isEqualTo("['resolved']"); + assertThat( + interpreter.render( + "{% set list = [] %}{% set bar = foo %}{% do list.append(bar['deferred']) %}{% print list %}" + ) + ) + .isEqualTo( + "{% set list = [] %}{% do list.append(good['deferred']) %}{% print list %}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/interpret/TemplateErrorTest.java b/src/test/java/com/hubspot/jinjava/interpret/TemplateErrorTest.java index 2564908a3..7abeec039 100644 --- a/src/test/java/com/hubspot/jinjava/interpret/TemplateErrorTest.java +++ b/src/test/java/com/hubspot/jinjava/interpret/TemplateErrorTest.java @@ -2,22 +2,125 @@ import static org.assertj.core.api.Assertions.assertThat; -import org.junit.Test; - import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import org.junit.Test; public class TemplateErrorTest { @Test public void itShowsFriendlyNameOfBaseObjectForPropNotFound() { - TemplateError e = TemplateError.fromUnknownProperty(new Object(), "foo", 123); + TemplateError e = TemplateError.fromUnknownProperty(new Object(), "foo", 123, 4); assertThat(e.getMessage()).isEqualTo("Cannot resolve property 'foo' in 'Object'"); } @Test public void itUsesOverloadedToStringForBaseObject() { - TemplateError e = TemplateError.fromUnknownProperty(ImmutableMap.of("foo", "bar"), "other", 123); - assertThat(e.getMessage()).isEqualTo("Cannot resolve property 'other' in '{foo=bar}'"); + TemplateError e = TemplateError.fromUnknownProperty( + ImmutableMap.of("foo", "bar"), + "other", + 123, + 4 + ); + assertThat(e.getMessage()) + .isEqualTo("Cannot resolve property 'other' in '{foo=bar}'"); } + @Test + public void itShowsFieldNameForUnknownTagError() { + TemplateError e = TemplateError.fromException( + new UnknownTagException("unKnown", "{% unKnown() %}", 11, 3) + ); + assertThat(e.getFieldName()).isEqualTo("unKnown"); + } + + @Test + public void itShowsFieldNameForSyntaxError() { + TemplateError e = TemplateError.fromException( + new TemplateSyntaxException("da codez", "{{ lolo lolo }}", 11) + ); + assertThat(e.getFieldName()).isEqualTo("da codez"); + } + + @Test + public void itRetainsFieldNameCaseForUnknownToken() { + JinjavaInterpreter interpreter = new Jinjava().newInterpreter(); + interpreter.render("{% unKnown() %}"); + assertThat(interpreter.getErrorsCopy().get(0).getFieldName()).isEqualTo("unKnown"); + } + + @Test + public void itSetsFieldNameCaseForSyntaxErrorInFor() { + RenderResult renderResult = new Jinjava() + .renderForResult("{% for item inna navigation %}{% endfor %}", ImmutableMap.of()); + assertThat(renderResult.getErrors().get(0).getFieldName()) + .isEqualTo("item inna navigation"); + } + + @Test + public void itLimitsErrorStringToAReasonableSize() { + String veryLong = ""; + + for (int i = 0; i < 1500; i++) { + veryLong = veryLong.concat("0"); + } + + TemplateError e = TemplateError.fromUnknownProperty( + ImmutableMap.of("foo", veryLong), + "other", + 123, + 4 + ); + assertThat(e.getMessage()).startsWith("Cannot resolve property 'other' in '{foo="); + assertThat(e.getMessage().length()).isLessThan(1500); + } + + @Test + public void itComparesEquality() { + TemplateError error1 = new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "error", + "badField", + 10, + 100, + new Exception(), + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("test1", "test2") + ); + + TemplateError error2 = new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "error", + "badField", + 10, + 100, + new Exception(), + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("test1", "test2") + ); + + TemplateError error3 = new TemplateError( + ErrorType.WARNING, + ErrorReason.SYNTAX_ERROR, + ErrorItem.TAG, + "error", + "differentField", + 10, + 100, + new Exception(), + BasicTemplateErrorCategory.FROM_CYCLE_DETECTED, + ImmutableMap.of("test1", "test2") + ); + + assertThat(error1).isEqualTo(error2); + assertThat(error1).isNotEqualTo(error3); + } } diff --git a/src/test/java/com/hubspot/jinjava/interpret/VariableFunctionTest.java b/src/test/java/com/hubspot/jinjava/interpret/VariableFunctionTest.java new file mode 100644 index 000000000..a0b8169f7 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/interpret/VariableFunctionTest.java @@ -0,0 +1,64 @@ +/** + * 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.hubspot.jinjava.interpret; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.Jinjava; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class VariableFunctionTest { + + private static final DynamicVariableResolver VARIABLE_FUNCTION = s -> { + switch (s) { + case "name": + return "Jared"; + case "title": + return "Mr."; + case "surname": + return "Stehler"; + default: + return null; + } + }; + + @Test + public void willUseTheFunctionToPopulateVariables() { + final Jinjava jinjava = new Jinjava(); + jinjava.getGlobalContext().setDynamicVariableResolver(VARIABLE_FUNCTION); + final Map context = new HashMap<>(); + + final String template = "
Hello, {{ title }} {{ name }} {{ surname }}!
"; + + final String renderedTemplate = jinjava.render(template, context); + + assertThat(renderedTemplate).isEqualTo("
Hello, Mr. Jared Stehler!
"); + } + + @Test + public void willPreferTheContextOverTheFunctionToPopulateVariables() { + final Jinjava jinjava = new Jinjava(); + jinjava.getGlobalContext().setDynamicVariableResolver(VARIABLE_FUNCTION); + final Map context = new HashMap<>(); + context.put("name", "Greg"); + + final String template = "
Hello, {{ title }} {{ name }} {{ surname }}!
"; + + final String renderedTemplate = jinjava.render(template, context); + + assertThat(renderedTemplate).isEqualTo("
Hello, Mr. Greg Stehler!
"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategyTest.java b/src/test/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategyTest.java new file mode 100644 index 000000000..01fb8b56a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/expression/EagerExpressionStrategyTest.java @@ -0,0 +1,248 @@ +package com.hubspot.jinjava.lib.expression; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.Context.Library; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.tree.ExpressionNodeTest; +import java.util.ArrayList; +import org.junit.Before; +import org.junit.Test; + +public class EagerExpressionStrategyTest extends ExpressionNodeTest { + + private Jinjava jinjava; + + class EagerExecutionModeNoRaw extends EagerExecutionMode { + + @Override + public boolean isPreserveRawTags() { + return false; // So that we can run all the ExpressionNodeTest tests without having the extra `{% raw %}` tags inserted + } + } + + @Before + public void eagerSetup() throws Exception { + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "is_deferred_execution_mode", + this.getClass().getDeclaredMethod("isDeferredExecutionMode") + ) + ); + interpreter = + new JinjavaInterpreter( + jinjava, + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(new EagerExecutionModeNoRaw()) + .build() + ); + nestedInterpreter = + new JinjavaInterpreter( + jinjava, + interpreter.getContext(), + BaseJinjavaTest + .newConfigBuilder() + .withNestedInterpretationEnabled(true) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + interpreter.getContext().put("deferred", DeferredValue.instance()); + nestedInterpreter.getContext().put("deferred", DeferredValue.instance()); + } + + @Test + public void itPreservesRawTags() { + interpreter = + new JinjavaInterpreter( + jinjava, + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + assertExpectedOutput( + interpreter, + "{{ '{{ foo }}' }} {{ '{% something %}' }} {{ 'not needed' }}", + "{% raw %}{{ foo }}{% endraw %} {% raw %}{% something %}{% endraw %} not needed" + ); + } + + @Test + public void itPreservesRawTagsNestedInterpretation() { + nestedInterpreter.getContext().put("bar", "bar"); + assertExpectedOutput( + nestedInterpreter, + "{{ '{{ 12345 }}' }} {{ '{% print bar %}' }} {{ 'not needed' }}", + "12345 bar not needed" + ); + } + + @Test + public void itPrependsMacro() { + assertExpectedOutput( + interpreter, + "{% macro foo(bar) %} {{ bar }} {% endmacro %}{{ foo(deferred) }}", + "{% macro foo(bar) %} {{ bar }} {% endmacro %}{{ foo(deferred) }}" + ); + } + + @Test + public void itPrependsSet() { + interpreter.getContext().put("foo", new PyList(new ArrayList<>())); + assertExpectedOutput( + interpreter, + "{{ foo.append(deferred) }}", + "{% set foo = [] %}{{ foo.append(deferred) }}" + ); + } + + @Test + public void itDoesConcatenation() { + interpreter.getContext().put("foo", "y'all"); + assertExpectedOutput( + interpreter, + "{{ 'oh, ' ~ foo ~ foo ~ ' toaster' }}", + "oh, y'ally'all toaster" + ); + } + + @Test + public void itHandlesQuotesLikeJinja() { + // {{ 'a|\'|\\\'|\\\\\'|"|\"|\\"|\\\\"|a ' ~ " b|\"|\\\"|\\\\\"|'|\'|\\'|\\\\'|b" }} + // --> a|'|\'|\\'|"|"|\"|\\"|a b|"|\"|\\"|'|'|\'|\\'|b + assertExpectedOutput( + interpreter, + "{{ 'a|\\'|\\\\\\'|\\\\\\\\\\'|\"|\\\"|\\\\\"|\\\\\\\\\"|a ' " + + "~ \" b|\\\"|\\\\\\\"|\\\\\\\\\\\"|'|\\'|\\\\'|\\\\\\\\'|b\" }}", + "a|'|\\'|\\\\'|\"|\"|\\\"|\\\\\"|a b|\"|\\\"|\\\\\"|'|'|\\'|\\\\'|b" + ); + } + + @Test + public void itGoesIntoDeferredExecutionMode() { + assertExpectedOutput( + interpreter, + "{{ is_deferred_execution_mode() }}" + + "{% if deferred %}{{ is_deferred_execution_mode() }}{% endif %}" + + "{{ is_deferred_execution_mode() }}", + "false{% if deferred %}true{% endif %}false" + ); + } + + @Test + public void itGoesIntoDeferredExecutionModeWithMacro() { + assertExpectedOutput( + interpreter, + "{% macro def() %}{{ is_deferred_execution_mode() }}{% endmacro %}" + + "{{ def() }}" + + "{% if deferred %}{{ def() }}{% endif %}" + + "{{ def() }}", + "false{% if deferred %}true{% endif %}false" + ); + } + + @Test + public void itDoesNotGoIntoDeferredExecutionModeUnnecessarily() { + assertExpectedOutput(interpreter, "{{ is_deferred_execution_mode() }}", "false"); + interpreter.getContext().setDeferredExecutionMode(true); + assertExpectedOutput(interpreter, "{{ is_deferred_execution_mode() }}", "true"); + } + + @Test + public void itDoesNotNestedInterpretIfThereAreFakeNotes() { + assertExpectedOutput( + nestedInterpreter, + "{{ '{#something_to_{{keep}}' }}", + "{#something_to_{{keep}}" + ); + } + + @Test + public void itDoesNotReconstructWithDoubleCurlyBraces() { + interpreter.getContext().put("foo", ImmutableMap.of("foo", ImmutableMap.of())); + assertExpectedOutput( + interpreter, + "{{ deferred ~ foo }}", + "{{ deferred ~ {'foo': {} } }}" + ); + } + + @Test + public void itDoesNotReconstructWithNestedDoubleCurlyBraces() { + interpreter + .getContext() + .put("foo", ImmutableMap.of("foo", ImmutableMap.of("bar", ImmutableMap.of()))); + assertExpectedOutput( + interpreter, + "{{ deferred ~ foo }}", + "{{ deferred ~ {'foo': {'bar': {} } } }}" + ); + } + + @Test + public void itDoesNotReconstructDirectlyWrittenWithDoubleCurlyBraces() { + assertExpectedOutput( + interpreter, + "{{ deferred ~ {\n'foo': {\n'bar': deferred\n}\n}\n }}", + "{{ deferred ~ {'foo': {'bar': deferred} } }}" + ); + } + + @Test + public void itReconstructsWithNestedInterpretation() { + interpreter.getContext().put("foo", "{{ print 'bar' }}"); + assertExpectedOutput( + interpreter, + "{{ deferred ~ foo }}", + "{{ deferred ~ '{{ print \\'bar\\' }}' }}" + ); + } + + @Test + public void itDoesNotDoNestedInterpretationWithSyntaxErrors() { + try ( + InterpreterScopeClosable c = nestedInterpreter.enterScope( + ImmutableMap.of(Library.TAG, ImmutableSet.of("print")) + ) + ) { + nestedInterpreter.getContext().put("foo", "{% print 'bar' %}"); + // Rather than rendering this to an empty string + assertExpectedOutput(nestedInterpreter, "{{ foo }}", "{% print 'bar' %}"); + } + } + + private void assertExpectedOutput( + JinjavaInterpreter interpreter, + String inputTemplate, + String expectedOutput + ) { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat(a.value().render(inputTemplate)).isEqualTo(expectedOutput); + } + } + + public static boolean isDeferredExecutionMode() { + return JinjavaInterpreter.getCurrent().getContext().isDeferredExecutionMode(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/BooleanExpTestsTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/BooleanExpTestsTest.java new file mode 100644 index 000000000..31c21de7b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/BooleanExpTestsTest.java @@ -0,0 +1,25 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class BooleanExpTestsTest extends BaseJinjavaTest { + + @Test + public void testIsBoolean() { + assertThat(jinjava.render("{{ 1 is boolean }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 'true' is boolean }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ true is boolean }}", new HashMap<>())) + .isEqualTo("true"); + } + + @Test + public void testBooleanExpTests() { + assertThat(jinjava.render("{{ true is true }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ true is false }}", new HashMap<>())).isEqualTo("false"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/ComparisonExpTestsTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/ComparisonExpTestsTest.java new file mode 100644 index 000000000..f2dd38e64 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/ComparisonExpTestsTest.java @@ -0,0 +1,69 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class ComparisonExpTestsTest extends BaseJinjavaTest { + + @Test + public void itComparesNumbers() { + assertThat(jinjava.render("{{ 4 is lt 5 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 5 is le 4 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is le 4 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is gt 5 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is gt 4 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is ge 4 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is ge 5 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is ne 5 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is ne 4 }}", new HashMap<>())).isEqualTo("false"); + } + + @Test + public void itComparesStringsLexicographically() { + assertThat(jinjava.render("{{ 'aa' is lt 'aa' }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ 'aa' is lt 'aaa' }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ 'aa' is lt 'b' }}", new HashMap<>())).isEqualTo("true"); + } + + @Test + public void itComparesDates() { + Map vars = ImmutableMap.of( + "now", + PyishDate.from(Instant.now()), + "then", + new PyishDate(1490171923745L) + ); + assertThat(jinjava.render("{{ now is lt then}}", vars)).isEqualTo("false"); + assertThat(jinjava.render("{{ then is lt now}}", vars)).isEqualTo("true"); + } + + @Test + public void itComparesAcrossType() { + assertThat(jinjava.render("{{ 4.1 is lt 5 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ true ne 'true' }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ true ne '' }}", new HashMap<>())).isEqualTo("true"); + } + + @Test + public void testAliases() { + assertThat(jinjava.render("{{ 4 is lessthan 5 }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is greaterthan 5 }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is < 5 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is > 5 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is <= 5 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is >= 5 }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is != 5 }}", new HashMap<>())).isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTestTest.java new file mode 100644 index 000000000..37e2d5cbd --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingAllExpTestTest.java @@ -0,0 +1,79 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsContainingAllExpTestTest extends BaseJinjavaTest { + + private static final String CONTAINING_TEMPLATE = + "{%% if %s is containingall %s %%}pass{%% else %%}fail{%% endif %%}"; + + @Test + public void itPassesOnContainedValues() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "[1, 2]"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } + + @Test + public void itPassesOnContainedDuplicatedValues() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "[1, 2, 2]"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } + + @Test + public void itFailsOnOnlySomeContainedValues() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "[1, 2, 4]"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnNullSequence() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "null", "[1, 2, 4]"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnNullValues() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "null"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itPerformsTypeConversion() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "['2', '3']"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTestTest.java new file mode 100644 index 000000000..bceab173d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsContainingExpTestTest.java @@ -0,0 +1,84 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsContainingExpTestTest extends BaseJinjavaTest { + + private static final String CONTAINING_TEMPLATE = + "{%% if %s is containing %s %%}pass{%% else %%}fail{%% endif %%}"; + + @Test + public void itPassesOnContainedValue() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "2"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } + + @Test + public void itFailsOnNullContainedValue() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, null]", "null"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnMissingValue() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "4"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnEmptyValue() { + assertThat( + jinjava.render(String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", ""), new HashMap<>()) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnNullValue() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "null"), + new HashMap<>() + ) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnNullSequence() { + assertThat( + jinjava.render(String.format(CONTAINING_TEMPLATE, "null", "2"), new HashMap<>()) + ) + .isEqualTo("fail"); + } + + @Test + public void itPerformsTypeConversion() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "'2'"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTestTest.java new file mode 100644 index 000000000..12c774ca4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsEqualToExpTestTest.java @@ -0,0 +1,122 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import org.junit.Test; + +public class IsEqualToExpTestTest extends BaseJinjavaTest { + + private static final String EQUAL_TEMPLATE = "{{ %s is equalto %s }}"; + + @Test + public void itEquatesNumbers() { + assertThat(jinjava.render(String.format(EQUAL_TEMPLATE, "4", "4"), new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render(String.format(EQUAL_TEMPLATE, "4", "5"), new HashMap<>())) + .isEqualTo("false"); + } + + @Test + public void itEquatesStrings() { + assertThat( + jinjava.render( + String.format(EQUAL_TEMPLATE, "\"jinjava\"", "\"jinjava\""), + new HashMap<>() + ) + ) + .isEqualTo("true"); + assertThat( + jinjava.render( + String.format(EQUAL_TEMPLATE, "\"jinjava\"", "\"not jinjava\""), + new HashMap<>() + ) + ) + .isEqualTo("false"); + } + + @Test + public void itEquatesCollectionsToStrings() { + assertThat( + jinjava.render( + String.format(EQUAL_TEMPLATE, "\"[1, 2, 3]\"", "[1, 2, 3]"), + new HashMap<>() + ) + ) + .isEqualTo("true"); + + assertThat( + jinjava.render( + String.format(EQUAL_TEMPLATE, "\"[1, 2, 3]\"", "[1, 2, 4]"), + new HashMap<>() + ) + ) + .isEqualTo("false"); + } + + @Test + public void itEquatesLargeCollectionsAndStrings() { + assertThat(compareStringAndCollection(100_000)).isEqualTo("true"); + } + + @Test + public void itDoesNotEquateHugeCollectionsAndStrings() { + assertThat(compareStringAndCollection(500_000)).isEqualTo("false"); + } + + private String compareStringAndCollection(int size) { + List bigList = new ArrayList<>(); + + for (int i = 0; i < size; i++) { + bigList.add(1); + } + + String bigString = bigList.toString(); + + return jinjava.render( + String.format(EQUAL_TEMPLATE, "\"" + bigString + "\"", bigString), + new HashMap<>() + ); + } + + @Test + public void itEquatesBooleans() { + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "true", "true"), new HashMap<>()) + ) + .isEqualTo("true"); + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "true", "false"), new HashMap<>()) + ) + .isEqualTo("false"); + } + + @Test + public void itEquatesDifferentTypes() { + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "4", "\"4\""), new HashMap<>()) + ) + .isEqualTo("true"); + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "4", "\"5\""), new HashMap<>()) + ) + .isEqualTo("false"); + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "'c'", "\"c\""), new HashMap<>()) + ) + .isEqualTo("true"); + assertThat( + jinjava.render(String.format(EQUAL_TEMPLATE, "'c'", "\"b\""), new HashMap<>()) + ) + .isEqualTo("false"); + } + + @Test + public void testAliases() { + assertThat(jinjava.render("{{ 4 is eq 4 }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4 is == 4 }}", new HashMap<>())).isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTestTest.java new file mode 100644 index 000000000..9667a6bbe --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsFloatExpTestTest.java @@ -0,0 +1,47 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsFloatExpTestTest extends BaseJinjavaTest { + + @Test + public void testValidFloats() { + assertThat(jinjava.render("{{ 4.1 is float }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 0.0 is float }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4e4 is float }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ 4e-30 is float }}", new HashMap<>())).isEqualTo("true"); + } + + @Test + public void testInvalidFloats() { + assertThat(jinjava.render("{{ 4 is float }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ -1 is float }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 0 is float }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ 'four point oh' is float }}", new HashMap<>())) + .isEqualTo("false"); + } + + @Test + public void testWithAddFilter() { + assertThat(jinjava.render("{{ (4|add(4)) is float }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ (4|add(4.5)) is float }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ (4|add(-4.5)) is float }}", new HashMap<>())) + .isEqualTo("true"); + assertThat( + jinjava.render("{{ (4|add(4.0000000000000000000001)) is float }}", new HashMap<>()) + ) + .isEqualTo("true"); + assertThat(jinjava.render("{{ (4|add(40.0)) is float }}", new HashMap<>())) + .isEqualTo("true"); + assertThat( + jinjava.render("{{ (4|add(1000000000000000000)) is float }}", new HashMap<>()) + ) + .isEqualTo("false"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsInExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsInExpTestTest.java new file mode 100644 index 000000000..6108a6fe9 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsInExpTestTest.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsInExpTestTest extends BaseJinjavaTest { + + @Test + public void testIsInList() { + assertThat(jinjava.render("{{ 2 is in [1, 2] }}", new HashMap<>())).isEqualTo("true"); + // TODO: Uncomment out when CollectionMemberShipOperator.java changes get approved + // assertThat(jinjava.render("{{ 2 is in ['one', 2] }}", new HashMap<>())) + // .isEqualTo("true"); + assertThat(jinjava.render("{{ 2 is in [1] }}", new HashMap<>())).isEqualTo("false"); + } + + @Test + public void testNull() { + assertThat(jinjava.render("{{ null is in [null] }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ null is in [2] }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ 2 is in [null] }}", new HashMap<>())) + .isEqualTo("false"); + assertThatThrownBy(() -> jinjava.render("{{ 2 is in null }}", new HashMap<>())) + .hasMessageContaining("1st argument with value 'null' must be iterable"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTestTest.java new file mode 100644 index 000000000..458767471 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsIntegerExpTestTest.java @@ -0,0 +1,56 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsIntegerExpTestTest extends BaseJinjavaTest { + + @Test + public void testValidIntegers() { + assertThat(jinjava.render("{{ 4 is integer }}", new HashMap<>())).isEqualTo("true"); + assertThat(jinjava.render("{{ -1 is integer }}", new HashMap<>())).isEqualTo("true"); + long number = Integer.MAX_VALUE; + assertThat( + jinjava.render(String.format("{{ %d is integer }}", number + 1), new HashMap<>()) + ) + .isEqualTo("true"); + assertThat(jinjava.render("{{ 1000000000000000000 is integer }}", new HashMap<>())) + .isEqualTo("true"); + } + + @Test + public void testInvalidIntegers() { + assertThat(jinjava.render("{{ 'four' is integer }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ false is integer }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ 4.1 is integer }}", new HashMap<>())) + .isEqualTo("false"); + } + + @Test + public void testWithAddFilter() { + assertThat(jinjava.render("{{ (4|add(4)) is integer }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ (4|add(4.5)) is integer }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ (4|add(-4.5)) is integer }}", new HashMap<>())) + .isEqualTo("false"); + assertThat( + jinjava.render( + "{{ (4|add(4.0000000000000000000001)) is integer }}", + new HashMap<>() + ) + ) + .isEqualTo("false"); + assertThat(jinjava.render("{{ (4|add(40.0)) is integer }}", new HashMap<>())) + .isEqualTo("false"); + assertThat( + jinjava.render("{{ (4|add(1000000000000000000)) is integer }}", new HashMap<>()) + ) + .isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTestTest.java new file mode 100644 index 000000000..ea68e0b74 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsIterableExpTestTest.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsIterableExpTestTest extends BaseJinjavaTest { + + @Test + public void testIsIterable() { + assertThat(jinjava.render("{{ null is iterable }}", new HashMap<>())) + .isEqualTo("false"); + assertThat(jinjava.render("{{ 4 is iterable }}", new HashMap<>())).isEqualTo("false"); + assertThat(jinjava.render("{{ [4] is iterable }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ [4, 'four'] is iterable }}", new HashMap<>())) + .isEqualTo("true"); + assertThat(jinjava.render("{{ 'this string' is iterable }}", new HashMap<>())) + .isEqualTo("false"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTestTest.java new file mode 100644 index 000000000..e582316f0 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringContainingExpTestTest.java @@ -0,0 +1,63 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Test; + +public class IsStringContainingExpTestTest extends BaseJinjavaTest { + + private static final String CONTAINING_TEMPLATE = "{{ var is string_containing arg }}"; + + @Test + public void itReturnsTrueForContainedString() { + assertThat( + jinjava.render( + CONTAINING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", "esti") + ) + ) + .isEqualTo("true"); + assertThat( + jinjava.render(CONTAINING_TEMPLATE, ImmutableMap.of("var", "testing", "arg", "")) + ) + .isEqualTo("true"); + assertThat( + jinjava.render( + CONTAINING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", "testing") + ) + ) + .isEqualTo("true"); + } + + @Test + public void itReturnsFalseForExcludedString() { + assertThat( + jinjava.render( + CONTAINING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", "blah") + ) + ) + .isEqualTo("false"); + } + + @Test + public void itReturnsFalseForNull() { + assertThat(jinjava.render(CONTAINING_TEMPLATE, ImmutableMap.of("var", "testing"))) + .isEqualTo("false"); + } + + @Test + public void itWorksForSafeString() { + assertThat( + jinjava.render( + CONTAINING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", new SafeString("testing")) + ) + ) + .isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTestTest.java new file mode 100644 index 000000000..0c3aa585c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringStartingWithExpTestTest.java @@ -0,0 +1,60 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Test; + +public class IsStringStartingWithExpTestTest extends BaseJinjavaTest { + + private static final String STARTING_TEMPLATE = "{{ var is string_startingwith arg }}"; + + @Test + public void itReturnsTrueForContainedString() { + assertThat( + jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", "testing", "arg", "tes")) + ) + .isEqualTo("true"); + assertThat( + jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", "testing", "arg", "")) + ) + .isEqualTo("true"); + assertThat( + jinjava.render( + STARTING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", "testing") + ) + ) + .isEqualTo("true"); + } + + @Test + public void itReturnsFalseForExcludedString() { + assertThat( + jinjava.render( + STARTING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", "esting") + ) + ) + .isEqualTo("false"); + } + + @Test + public void itReturnsFalseForNull() { + assertThat(jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", "testing"))) + .isEqualTo("false"); + } + + @Test + public void itWorksForSafeString() { + assertThat( + jinjava.render( + STARTING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", new SafeString("tes")) + ) + ) + .isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTestTest.java new file mode 100644 index 000000000..e6de57dbc --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsUpperExpTestTest.java @@ -0,0 +1,36 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Test; + +public class IsUpperExpTestTest extends BaseJinjavaTest { + + private static final String STARTING_TEMPLATE = "{{ var is upper }}"; + private static final String SAFE_TEMPLATE = "{{ (var|safe) is upper }}"; + + @Test + public void itReturnsTrueForUpperString() { + assertThat(jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", "UPPER"))) + .isEqualTo("true"); + } + + @Test + public void itReturnsFalseForLowerString() { + assertThat(jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", "lower"))) + .isEqualTo("false"); + } + + @Test + public void itWorksForSafeStrings() { + assertThat( + jinjava.render(STARTING_TEMPLATE, ImmutableMap.of("var", new SafeString("UPPER"))) + ) + .isEqualTo("true"); + assertThat(jinjava.render(SAFE_TEMPLATE, ImmutableMap.of("var", "UPPER"))) + .isEqualTo("true"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTestTest.java new file mode 100644 index 000000000..664ec5e1b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsWithinExpTestTest.java @@ -0,0 +1,60 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class IsWithinExpTestTest extends BaseJinjavaTest { + + private static final String IN_TEMPLATE = + "{%% if %s is within %s %%}pass{%% else %%}fail{%% endif %%}"; + + @Test + public void itPassesOnValueInSequence() { + assertThat( + jinjava.render(String.format(IN_TEMPLATE, "2", "[1, 2, 3]"), new HashMap<>()) + ) + .isEqualTo("pass"); + } + + // TODO: Uncomment out when ColectionMemberShipOperator.java changes get approved + // @Test + // public void itPassesOnNullValueInSequence() { + // assertThat( + // jinjava.render( + // String.format(IN_TEMPLATE, "null", "[1, 2, null]"), + // new HashMap<>() + // ) + // ) + // .isEqualTo("pass"); + // } + + @Test + public void itFailsOnValueNotInSequence() { + assertThat( + jinjava.render(String.format(IN_TEMPLATE, "4", "[1, 2, 3]"), new HashMap<>()) + ) + .isEqualTo("fail"); + } + + @Test + public void itFailsOnNullValueNotInSequence() { + assertThat( + jinjava.render(String.format(IN_TEMPLATE, "null", "[1, 2, 3]"), new HashMap<>()) + ) + .isEqualTo("fail"); + } + + @Test + public void itPerformsTypeConversion() { + assertThat( + jinjava.render( + String.format(IN_TEMPLATE, "'1'", "[100000000000, 1]"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/NegatedExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/NegatedExpTestTest.java new file mode 100644 index 000000000..58f3c0700 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/NegatedExpTestTest.java @@ -0,0 +1,38 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class NegatedExpTestTest extends BaseJinjavaTest { + + private static final String TEMPLATE = + "{%% if %s is %s %s %%}pass{%% else %%}fail{%% endif %%}"; + private static final String CONTAINING_TEMPLATE = + "{%% if %s is not containing %s %%}pass{%% else %%}fail{%% endif %%}"; + + @Test + public void itNegatesDefined() { + assertThat( + jinjava.render(String.format(TEMPLATE, "blah", "", "defined"), new HashMap<>()) + ) + .isEqualTo("fail"); + assertThat( + jinjava.render(String.format(TEMPLATE, "blah", "not", "defined"), new HashMap<>()) + ) + .isEqualTo("pass"); + } + + @Test + public void itNegatesContaining() { + assertThat( + jinjava.render( + String.format(CONTAINING_TEMPLATE, "[1, 2, 3]", "4"), + new HashMap<>() + ) + ) + .isEqualTo("pass"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/isDivisibleByExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/isDivisibleByExpTestTest.java new file mode 100644 index 000000000..a9976a30c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/isDivisibleByExpTestTest.java @@ -0,0 +1,127 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.junit.Assert.assertEquals; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import java.util.HashMap; +import org.junit.Test; + +public class isDivisibleByExpTestTest extends BaseJinjavaTest { + + private static final String DIVISIBLE_BY_TEMPLATE = "{{ %s is divisibleby %s }}"; + + @Test + public void itRequiresDividend() { + assertEquals( + jinjava.render(String.format(DIVISIBLE_BY_TEMPLATE, "null", "3"), new HashMap<>()), + "false" + ); + } + + @Test + public void itRequiresNumericalDividend() { + assertEquals( + jinjava.render(String.format(DIVISIBLE_BY_TEMPLATE, "'foo'", "3"), new HashMap<>()), + "false" + ); + } + + @Test + public void itReturnsFalseForFractionalDividend() { + assertEquals( + jinjava.render( + String.format(DIVISIBLE_BY_TEMPLATE, "10.00001", "5"), + new HashMap<>() + ), + "false" + ); + } + + @Test + public void itRequiresDivisor() { + assertEquals( + jinjava.render(String.format(DIVISIBLE_BY_TEMPLATE, "10", "null"), new HashMap<>()), + "false" + ); + } + + @Test + public void itRequiresNumericalDivisor() { + RenderResult result = jinjava.renderForResult( + String.format(DIVISIBLE_BY_TEMPLATE, "10", "'foo'"), + new HashMap<>() + ); + assertOnInvalidArgument(result, "must be a number"); + } + + @Test + public void itRequiresNonZeroDivisor() { + RenderResult result = jinjava.renderForResult( + String.format(DIVISIBLE_BY_TEMPLATE, "10", "0"), + new HashMap<>() + ); + assertOnInvalidArgument(result, "1st argument with value 0 must be non-zero"); + } + + @Test + public void itReturnsTrueWhenDividendIsDivisibleByDivisor() { + assertEquals( + jinjava.render(String.format(DIVISIBLE_BY_TEMPLATE, "10", "5"), new HashMap<>()), + "true" + ); + } + + @Test + public void itReturnsTrueForDecimalDividendWithNoFractionalPart() { + assertEquals( + jinjava.render( + String.format(DIVISIBLE_BY_TEMPLATE, "10.00000", "5"), + new HashMap<>() + ), + "true" + ); + } + + @Test + public void itReturnsFalseWhenDividendIsNotMultipleOfDivisor() { + assertEquals( + jinjava.render(String.format(DIVISIBLE_BY_TEMPLATE, "10", "3"), new HashMap<>()), + "false" + ); + } + + @Test + public void itReturnsFalseForFractionalDivisor() { + RenderResult result = jinjava.renderForResult( + String.format(DIVISIBLE_BY_TEMPLATE, "10", "5.00001"), + new HashMap<>() + ); + assertOnInvalidArgument(result, "must be non-zero"); + } + + @Test + public void itReturnsTrueForDecimalDivisorWithNoFractionalPart() { + assertEquals( + jinjava.render( + String.format(DIVISIBLE_BY_TEMPLATE, "10", "5.00000"), + new HashMap<>() + ), + "true" + ); + } + + private void assertOnInvalidArgument( + RenderResult result, + String expectedMessageIfInvalid + ) { + assertEquals(result.getErrors().size(), 1); + TemplateError error = result.getErrors().get(0); + assertEquals(error.getSeverity(), ErrorType.FATAL); + assertEquals(error.getReason(), ErrorReason.INVALID_ARGUMENT); + assertEquals(error.getMessage().contains(expectedMessageIfInvalid), true); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/AbstractFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/AbstractFilterTest.java new file mode 100644 index 000000000..5a449af72 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/AbstractFilterTest.java @@ -0,0 +1,167 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class AbstractFilterTest extends BaseInterpretingTest { + + private ArgCapturingFilter filter; + + public static class NoJinjavaDocFilter extends ArgCapturingFilter {} + + @Test + public void itErrorsWhenNoJinjavaDoc() { + assertThatThrownBy(() -> new NoJinjavaDocFilter()) + .hasMessageContaining("@JinjavaDoc must be configured"); + } + + @JinjavaDoc + public static class NoJinjavaParamsFilter extends ArgCapturingFilter {} + + @Test + public void itDoesNotRequireParams() { + filter = new NoJinjavaParamsFilter(); + } + + @JinjavaDoc( + params = { + @JinjavaParam(value = "1st", desc = "1st"), + @JinjavaParam(value = "2nd", desc = "2nd"), + @JinjavaParam(value = "3rd", desc = "3rd"), + } + ) + public static class TwoParamTypesFilter extends ArgCapturingFilter {} + + @Test + public void itSupportsMixingOfPositionalAndNamedArgs() { + filter = new TwoParamTypesFilter(); + + filter.filter(null, interpreter, new Object[] { "1" }, ImmutableMap.of("3rd", "3")); + + assertThat(filter.parsedArgs).isEqualTo(ImmutableMap.of("1st", "1", "3rd", "3")); + } + + @JinjavaDoc( + params = { + @JinjavaParam( + value = "boolean", + type = "boolean", + desc = "boolean", + required = true + ), + @JinjavaParam(value = "int", type = "int", desc = "int"), + @JinjavaParam(value = "long", type = "long", desc = "long"), + @JinjavaParam(value = "float", type = "float", desc = "float"), + @JinjavaParam(value = "double", type = "double", desc = "double"), + @JinjavaParam(value = "number", type = "number", desc = "number"), + @JinjavaParam(value = "object", type = "object", desc = "object"), + @JinjavaParam(value = "dict", type = "dict", desc = "dict"), + } + ) + public static class AllParamTypesFilter extends ArgCapturingFilter {} + + @Test + public void itParsesNumericAndBooleanInput() { + filter = new AllParamTypesFilter(); + + Map kwArgs = ImmutableMap + .builder() + .put("boolean", "true") + .put("int", "1") + .put("long", "2") + .put("double", "3") + .put("float", "4") + .put("number", "5") + .put("object", new Object()) + .put("dict", new Object()) + .build(); + + filter.filter(null, interpreter, new Object[] {}, kwArgs); + + Map expected = ImmutableMap + .builder() + .put("boolean", true) + .put("int", 1) + .put("long", 2L) + .put("double", 3.0) + .put("float", 4.0f) + .put("number", new BigDecimal(5)) + .put("object", kwArgs.get("object")) + .put("dict", kwArgs.get("dict")) + .build(); + + assertThat(filter.parsedArgs).isEqualTo(expected); + } + + @Test + public void itValidatesRequiredArgs() { + filter = new AllParamTypesFilter(); + + assertThatThrownBy(() -> + filter.filter(null, interpreter, new Object[] {}, Collections.emptyMap()) + ) + .hasMessageContaining("Argument named 'boolean' is required"); + } + + @Test + public void itErrorsOnTooManyArgs() { + filter = new AllParamTypesFilter(); + + assertThatThrownBy(() -> + filter.filter( + null, + interpreter, + new Object[] { true, null, null, null, null, null, null, null, null }, + Collections.emptyMap() + ) + ) + .hasMessageContaining("Argument at index") + .hasMessageContaining("is invalid"); + } + + @Test + public void itErrorsUnknownNamedArg() { + filter = new AllParamTypesFilter(); + + assertThatThrownBy(() -> + filter.filter( + null, + interpreter, + new Object[] { true }, + ImmutableMap.of("unknown", "unknown") + ) + ) + .hasMessageContaining("Argument named 'unknown' is invalid"); + } + + public static class ArgCapturingFilter extends AbstractFilter { + + public Map parsedArgs; + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Map parsedArgs + ) { + this.parsedArgs = parsedArgs; + return null; + } + + @Override + public String getName() { + return getClass().getSimpleName(); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/AbstractSetFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/AbstractSetFilterTest.java new file mode 100644 index 000000000..dde125123 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/AbstractSetFilterTest.java @@ -0,0 +1,139 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; + +public class AbstractSetFilterTest extends BaseJinjavaTest { + + private static final IntersectFilter concreteSetFilter = new IntersectFilter(); + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itDoesNotThrowWarningOnMatchedTypes() { + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + + // {{ [1, 2, 3]|intersect([1, 2, 3]) }} + Set varSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + Set argSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + concreteSetFilter.attachMismatchedTypesWarning(interpreter, varSet, argSet); + + List errors = interpreter.getErrors(); + assertThat(errors).isEmpty(); + } + + @Test + public void itDoesNotThrowWarningOnEmptyVarSet() { + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + + String renderedOutput = interpreter.render("{{ []|intersect([1, 2, 3]) }}"); + assertThat(renderedOutput).isEqualTo("[]"); + + // {{ []|intersect([1, 2, 3]) }} + Set varSet = concreteSetFilter.objectToSet(new Object[] {}); + Set argSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + concreteSetFilter.attachMismatchedTypesWarning(interpreter, varSet, argSet); + + List errors = interpreter.getErrors(); + assertThat(errors).isEmpty(); + } + + @Test + public void itDoesNotThrowWarningOnEmptyArgSet() { + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + + // {{ [1, 2, 3]|intersect([]) }} + Set varSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + Set argSet = concreteSetFilter.objectToSet(new Object[] {}); + concreteSetFilter.attachMismatchedTypesWarning(interpreter, varSet, argSet); + + List errors = interpreter.getErrors(); + assertThat(errors).isEmpty(); + } + + @Test + public void itThrowsWarningOnMismatchTypes() { + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + + // {{ [1, 2, 3]|intersect(['1', '2', '3']) }} + Set varSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + Set argSet = concreteSetFilter.objectToSet(new String[] { "1", "2", "3" }); + concreteSetFilter.attachMismatchedTypesWarning(interpreter, varSet, argSet); + + List errors = interpreter.getErrors(); + assertThat(errors).isNotEmpty(); + + TemplateError error = errors.get(0); + assertThat(error.getSeverity()).isEqualTo(TemplateError.ErrorType.WARNING); + assertThat(error.getMessage()) + .isEqualTo( + "Mismatched Types: input set has elements of type 'long' but arg set has elements of type 'str'. Use |map filter to convert sets to the same type for filter to work correctly." + ); + } + + @Test + public void itDoesNotThrowWarningOnIntegerLongMismatch() { + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + + Set varSet = concreteSetFilter.objectToSet(new Long[] { 1L, 2L, 3L }); + Set argSet = concreteSetFilter.objectToSet(new Integer[] { 1, 2, 3 }); + concreteSetFilter.attachMismatchedTypesWarning(interpreter, varSet, argSet); + + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itConvertsIntegerToLongWhenFeatureActive() { + Jinjava jinjavaWithFeature = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add(BuiltInFeatures.INTEGER_SET_TO_LONG_CONVERSION, FeatureStrategies.ACTIVE) + .build() + ) + .build() + ); + + Map vars = new HashMap<>(); + vars.put("longList", new Long[] { 1L, 2L, 3L }); + vars.put("intList", new Integer[] { 2, 3, 4 }); + + String result = jinjavaWithFeature.render("{{ longList|intersect(intList) }}", vars); + + assertThat(result).isEqualTo("[2, 3]"); + } + + @Test + public void itDoesNotConvertWhenFeatureInactive() { + Map vars = new HashMap<>(); + vars.put("longList", new Long[] { 1L, 2L, 3L }); + vars.put("intList", new Integer[] { 2, 3, 4 }); + + RenderResult renderResult = jinjava.renderForResult( + "{{ longList|intersect(intList) }}", + vars + ); + + assertThat(renderResult.getOutput()).isEqualTo("[]"); + assertThat(renderResult.getErrors()).isEmpty(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/AdvancedFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/AdvancedFilterTest.java new file mode 100644 index 000000000..6988a94f6 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/AdvancedFilterTest.java @@ -0,0 +1,169 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class AdvancedFilterTest extends BaseJinjavaTest { + + @Test + public void testOnlyArgs() { + Object[] expectedArgs = new Object[] { 3L, 1L }; + Map expectedKwargs = new HashMap<>(); + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat(jinjava.render("{{ 'test'|mirror(3, 1) }}", new HashMap<>())) + .isEqualTo("test"); + } + + @Test + public void testOnlyKwargs() { + Object[] expectedArgs = new Object[] {}; + Map expectedKwargs = new HashMap() { + { + put("named10", "str"); + put("named2", 3L); + put("namedB", true); + } + }; + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat( + jinjava.render( + "{{ 'test'|mirror(named2=3, named10='str', namedB=true) }}", + new HashMap<>() + ) + ) + .isEqualTo("test"); + } + + @Test + public void itTestsNullKwargs() { + Object[] expectedArgs = new Object[] {}; + Map expectedKwargs = new HashMap() { + { + put("named1", null); + } + }; + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat(jinjava.render("{{ 'test'|divide(named1) }}", new HashMap<>())) + .isEqualTo("test"); + } + + @Test + public void testMixedArgsAndKwargs() { + Object[] expectedArgs = new Object[] { 1L, 2L }; + Map expectedKwargs = new HashMap() { + { + put("named", "test"); + } + }; + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat(jinjava.render("{{ 'test'|mirror(1, 2, named='test') }}", new HashMap<>())) + .isEqualTo("test"); + } + + @Test + public void testUnorderedArgsAndKwargs() { + Object[] expectedArgs = new Object[] { "1", 2L }; + Map expectedKwargs = new HashMap() { + { + put("named", "test"); + } + }; + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat( + jinjava.render("{{ 'test'|mirror('1', named='test', 2) }}", new HashMap<>()) + ) + .isEqualTo("test"); + } + + @Test + public void testRepeatedKwargs() { + Object[] expectedArgs = new Object[] { true }; + Map expectedKwargs = new HashMap() { + { + put("named", "overwrite"); + } + }; + + jinjava + .getGlobalContext() + .registerFilter(new MyMirrorFilter(expectedArgs, expectedKwargs)); + + assertThat( + jinjava.render( + "{{ 'test'|mirror(true, named='test', named='overwrite') }}", + new HashMap<>() + ) + ) + .isEqualTo("test"); + } + + private static class MyMirrorFilter implements AdvancedFilter { + + private Object[] expectedArgs; + private Map expectedKwargs; + + MyMirrorFilter(Object[] args, Map kwargs) { + this.expectedArgs = args; + this.expectedKwargs = kwargs; + } + + @Override + public String getName() { + return "mirror"; + } + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + if (!Arrays.equals(expectedArgs, args)) { + throw new RuntimeException( + "Args are different than expected: " + + Arrays.toString(args) + + " to " + + Arrays.toString(expectedArgs) + ); + } + + if (!expectedKwargs.equals(kwargs)) { + throw new RuntimeException( + "Kwargs are different than expected: " + + Arrays.toString(kwargs.entrySet().toArray()) + + " to " + + Arrays.toString(expectedKwargs.entrySet().toArray()) + ); + } + + return var; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilterTest.java new file mode 100644 index 000000000..5db6305b5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/AllowSnakeCaseFilterTest.java @@ -0,0 +1,28 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import org.junit.Test; + +public class AllowSnakeCaseFilterTest extends BaseInterpretingTest { + + @Test + public void itDoesNotChangeNonMaps() { + assertThat(interpreter.render("{{ 'fooBar'|allow_snake_case }}")).isEqualTo("fooBar"); + } + + @Test + public void itMakesMapKeysAccessibleWithSnakeCase() { + assertThat(interpreter.render("{{ ({'fooBar': 'foo'}|allow_snake_case).foo_bar }}")) + .isEqualTo("foo"); + } + + @Test + public void itReserializesAsSnakeCaseAccessibleMap() { + interpreter.render("{% set map = {'fooBar': 'foo'}|allow_snake_case %}"); + assertThat(PyishObjectMapper.getAsPyishString(interpreter.getContext().get("map"))) + .isEqualTo("{'fooBar': 'foo'} |allow_snake_case"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/AttrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/AttrFilterTest.java index 41fc7db99..812f9ba95 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/AttrFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/AttrFilterTest.java @@ -2,24 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import java.util.HashMap; import java.util.Map; - -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.RenderResult; -import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; - -public class AttrFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class AttrFilterTest extends BaseJinjavaTest { @Test public void testAttr() { @@ -34,10 +24,14 @@ public void testAttrNotFound() { Map context = new HashMap<>(); context.put("foo", new MyFoo()); - RenderResult renderResult = jinjava.renderForResult("{{ foo|attr(\"barf\") }}", context); + RenderResult renderResult = jinjava.renderForResult( + "{{ foo|attr(\"barf\") }}", + context + ); assertThat(renderResult.getOutput()).isEmpty(); assertThat(renderResult.getErrors()).hasSize(1); - assertThat(renderResult.getErrors().get(0).getReason()).isEqualTo(ErrorReason.UNKNOWN); + assertThat(renderResult.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.UNKNOWN); assertThat(renderResult.getErrors().get(0).getFieldName()).isEqualTo("barf"); } @@ -49,7 +43,20 @@ public void testAttrNull() { assertThat(jinjava.render("{{ foo|attr(\"null_val\") }}", context)).isEqualTo(""); } + @Test + public void itAddsErrorOnNullAttribute() { + Map context = new HashMap<>(); + context.put("foo", new MyFoo()); + context.put("filter", null); + + RenderResult result = jinjava.renderForResult("{{ foo|attr(filter) }}", context); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("'name' argument cannot be null"); + } + public static class MyFoo { + public String getBar() { return "mybar"; } @@ -58,5 +65,4 @@ public String getNullVal() { return null; } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/Base64FilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/Base64FilterTest.java new file mode 100644 index 000000000..eb4563949 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/Base64FilterTest.java @@ -0,0 +1,88 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import java.util.Collections; +import org.junit.Test; + +public class Base64FilterTest extends BaseJinjavaTest { + + @Test + public void itEncodesWithDefaultCharset() { + assertThat(jinjava.render("{{ 'ß'|b64encode }}", Collections.emptyMap())) + .isEqualTo("w58="); + } + + @Test + public void itEncodesWithUtf16Le() { + assertThat( + jinjava.render( + "{{ '\uD801\uDC37'|b64encode(encoding='utf-16le') }}", + Collections.emptyMap() + ) + ) + .isEqualTo("Adg33A=="); + } + + @Test + public void itDecodesWithUtf16Le() { + assertThat( + jinjava.render( + "{{ 'Adg33A=='|b64decode(encoding='utf-16le') }}", + Collections.emptyMap() + ) + ) + .isEqualTo("\uD801\uDC37"); + } + + @Test + public void itEncodesAndDecodesDefaultCharset() { + assertThat( + jinjava.render("{{ 123456789|b64encode|b64decode }}", Collections.emptyMap()) + ) + .isEqualTo("123456789"); + } + + @Test + public void itEncodesAndDecodesUtf16Le() { + assertThat( + jinjava.render( + "{{ 123456789|b64encode(encoding='utf-16le')|b64decode(encoding='utf-16le') }}", + Collections.emptyMap() + ) + ) + .isEqualTo("123456789"); + } + + @Test + public void itEncodesObject() { + assertThat(jinjava.render("{{ {'foo': ['bar']}|b64encode }}", Collections.emptyMap())) + .isEqualTo("eydmb28nOiBbJ2JhciddfQ=="); + } + + @Test + public void itHandlesInvalidDecode() { + RenderResult renderResult = jinjava.renderForResult( + "{{ 'ß'|b64decode }}", + Collections.emptyMap() + ); + assertThat(renderResult.getErrors()).hasSize(1); + assertThat(renderResult.getErrors().get(0).getException()) + .isInstanceOf(TemplateSyntaxException.class); + } + + @Test + public void itThrowsErrorForNonStringDecode() { + RenderResult renderResult = jinjava.renderForResult( + "{{ 123|b64decode }}", + Collections.emptyMap() + ); + assertThat(renderResult.getErrors()).hasSize(1); + assertThat(renderResult.getErrors().get(0).getException()) + .isInstanceOf(InvalidArgumentException.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/BatchFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/BatchFilterTest.java index 176068fa1..10f512eff 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/BatchFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/BatchFilterTest.java @@ -2,34 +2,25 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; import java.nio.charset.StandardCharsets; import java.util.Map; - import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.select.Elements; -import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; - -public class BatchFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class BatchFilterTest extends BaseJinjavaTest { @Test public void batchFilterNoBackfill() { - Map context = ImmutableMap.of("items", (Object) Lists.newArrayList( - "1", "2", "3", "4", "5", "6")); + Map context = ImmutableMap.of( + "items", + (Object) Lists.newArrayList("1", "2", "3", "4", "5", "6") + ); Document dom = Jsoup.parseBodyFragment(render("batch-filter", context)); assertThat(dom.select("tr")).hasSize(2); @@ -47,8 +38,10 @@ public void batchFilterNoBackfill() { @Test public void batchFilterFillMissing() { - Map context = ImmutableMap.of("items", (Object) Lists.newArrayList( - "1", "2", "3", "4")); + Map context = ImmutableMap.of( + "items", + (Object) Lists.newArrayList("1", "2", "3", "4") + ); Document dom = Jsoup.parseBodyFragment(render("batch-filter", context)); assertThat(dom.select("tr")).hasSize(2); @@ -66,10 +59,15 @@ public void batchFilterFillMissing() { private String render(String template, Map context) { try { - return jinjava.render(Resources.toString(Resources.getResource(String.format("filter/%s.jinja", template)), StandardCharsets.UTF_8), context); + return jinjava.render( + Resources.toString( + Resources.getResource(String.format("filter/%s.jinja", template)), + StandardCharsets.UTF_8 + ), + context + ); } catch (Exception e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilterTest.java new file mode 100644 index 000000000..710d13780 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/BetweenTimesFilterTest.java @@ -0,0 +1,61 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Map; +import org.junit.Test; + +public class BetweenTimesFilterTest extends BaseJinjavaTest { + + @Test + public void itGetsDurationBetweenTimes() { + long timestamp = 1543354954000L; + long oneDay = 1543441354000L; + long twoMonths = 1548668554000L; + + Map vars = ImmutableMap.of( + "begin", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC), + "end", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(oneDay), ZoneOffset.UTC), + "endMonth", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(twoMonths), ZoneOffset.UTC) + ); + + assertThat(jinjava.render("{{ begin|between_times(end, 'days') }}", vars)) + .isEqualTo("1"); + assertThat(jinjava.render("{{ begin|between_times(end, 'hours') }}", vars)) + .isEqualTo("24"); + assertThat(jinjava.render("{{ begin|between_times(end, 'minutes') }}", vars)) + .isEqualTo("1440"); + assertThat(jinjava.render("{{ begin|between_times(end, 'seconds') }}", vars)) + .isEqualTo("86400"); + assertThat(jinjava.render("{{ begin|between_times(endMonth, 'months') }}", vars)) + .isEqualTo("2"); + + vars = + ImmutableMap.of( + "begin", + new PyishDate( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ), + "end", + new PyishDate( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(oneDay), ZoneOffset.UTC) + ) + ); + + assertThat(jinjava.render("{{ begin|between_times(end, 'days') }}", vars)) + .isEqualTo("1"); + + vars = ImmutableMap.of("begin", timestamp, "end", oneDay); + assertThat(jinjava.render("{{ begin|between_times(end, 'days') }}", vars)) + .isEqualTo("1"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/BoolFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/BoolFilterTest.java new file mode 100644 index 000000000..e34a2a97a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/BoolFilterTest.java @@ -0,0 +1,59 @@ +/** + * 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.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import org.junit.Before; +import org.junit.Test; + +public class BoolFilterTest extends BaseInterpretingTest { + + BoolFilter filter; + + @Before + public void setup() { + filter = new BoolFilter(); + } + + @Test + public void itReturnsStringAsBool() { + assertThat(filter.filter("true", interpreter)).isEqualTo(true); + assertThat(filter.filter("false", interpreter)).isEqualTo(false); + } + + @Test + public void itReturnsIntAsBool() { + assertThat(filter.filter(Integer.valueOf(0), interpreter)).isEqualTo(false); + assertThat(filter.filter(Integer.valueOf(1), interpreter)).isEqualTo(true); + assertThat(filter.filter(Integer.valueOf(2), interpreter)).isEqualTo(false); + } + + @Test + public void itReturnsFalseWhenVarIsNull() { + assertThat(filter.filter(null, interpreter)).isEqualTo(false); + } + + @Test + public void itReturnsFalseWhenVarIsString() { + assertThat(filter.filter("foobar", interpreter)).isEqualTo(false); + } + + @Test + public void itReturnsSameWhenVarIsBool() { + assertThat(filter.filter(false, interpreter)).isEqualTo(false); + assertThat(filter.filter(true, interpreter)).isEqualTo(true); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/CamelCaseRegisteringFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/CamelCaseRegisteringFilterTest.java new file mode 100644 index 000000000..148c199c6 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/CamelCaseRegisteringFilterTest.java @@ -0,0 +1,38 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class CamelCaseRegisteringFilterTest extends BaseJinjavaTest { + + @Test + public void itAllowsCamelCasedFilterNames() { + jinjava.getGlobalContext().registerFilter(new ReturnHelloFilter()); + + assertThat(jinjava.render("{{ 'test'|returnHello }}", new HashMap<>())) + .isEqualTo("Hello"); + } + + private static class ReturnHelloFilter implements AdvancedFilter { + + @Override + public String getName() { + return "returnHello"; + } + + @Override + public Object filter( + Object var, + JinjavaInterpreter interpreter, + Object[] args, + Map kwargs + ) { + return "Hello"; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/CapitalizeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/CapitalizeFilterTest.java index f27c039c2..7366e4b09 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/CapitalizeFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/CapitalizeFilterTest.java @@ -7,8 +7,19 @@ public class CapitalizeFilterTest { @Test - public void testCapitalize() { + public void itCapitalizesNormalValues() { assertThat(new CapitalizeFilter().filter("foo", null)).isEqualTo("Foo"); } + @Test + public void itCapitalizesSentences() { + assertThat(new CapitalizeFilter().filter("foo is the best", null)) + .isEqualTo("Foo is the best"); + } + + @Test + public void itLowercasesUppercasedCharsInSentences() { + assertThat(new CapitalizeFilter().filter("foo is the bEST", null)) + .isEqualTo("Foo is the best"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilterTest.java new file mode 100644 index 000000000..c733d9835 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/CloseHtmlFilterTest.java @@ -0,0 +1,36 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import org.junit.Before; +import org.junit.Test; + +public class CloseHtmlFilterTest extends BaseInterpretingTest { + + CloseHtmlFilter f; + + @Before + public void setup() { + f = new CloseHtmlFilter(); + } + + @Test + public void itClosesTags() { + String openTags = "

Hello, world!"; + assertThat(f.filter(openTags, interpreter)).isEqualTo("

Hello, world!

"); + } + + @Test + public void itIgnoresClosedTags() { + String openTags = "

Hello, world!

"; + assertThat(f.filter(openTags, interpreter)).isEqualTo("

Hello, world!

"); + } + + @Test + public void itClosesMultipleTags() { + String openTags = "

Hello, world!"; + assertThat(f.filter(openTags, interpreter)) + .isEqualTo("

Hello, world!

"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilterTest.java new file mode 100644 index 000000000..3234d0111 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DAliasedDefaultFilterTest.java @@ -0,0 +1,18 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Test; + +public class DAliasedDefaultFilterTest extends BaseJinjavaTest { + + @Test + public void itSetsDefaultStringValues() { + assertThat( + jinjava.render("{% set d=d |d(\"some random value\") %}{{ d }}", new HashMap<>()) + ) + .isEqualTo("some random value"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilterTest.java index 4a8919076..5c84262c8 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DateTimeFormatFilterTest.java @@ -1,21 +1,30 @@ package com.hubspot.jinjava.lib.filter; +import static com.hubspot.jinjava.lib.filter.time.DateTimeFormatHelper.FIXED_DATE_TIME_FILTER_NULL_ARG; import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.lib.fn.Functions; +import com.hubspot.jinjava.objects.date.InvalidDateFormatException; +import com.hubspot.jinjava.objects.date.StrftimeFormatter; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Locale; - import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.objects.date.StrftimeFormatter; - -public class DateTimeFormatFilterTest { +public class DateTimeFormatFilterTest extends BaseInterpretingTest { - JinjavaInterpreter interpreter; DateTimeFormatFilter filter; ZonedDateTime d; @@ -23,39 +32,179 @@ public class DateTimeFormatFilterTest { @Before public void setup() { Locale.setDefault(Locale.ENGLISH); - interpreter = new Jinjava().newInterpreter(); filter = new DateTimeFormatFilter(); d = ZonedDateTime.parse("2013-11-06T14:22:00.000+00:00[UTC]"); } @Test - public void itUsesTodayIfNoDateProvided() throws Exception { - assertThat(filter.filter(null, interpreter)).isEqualTo(StrftimeFormatter.format(ZonedDateTime.now(ZoneOffset.UTC))); + public void itUsesTodayIfNoDateProvided() { + assertThat(filter.filter(null, interpreter)) + .isEqualTo(StrftimeFormatter.format(ZonedDateTime.now(ZoneOffset.UTC))); + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains("datetimeformat filter called with null datetime"); } @Test - public void itSupportsLongAsInput() throws Exception { + public void itSupportsLongAsInput() { assertThat(filter.filter(d, interpreter)).isEqualTo(StrftimeFormatter.format(d)); } @Test - public void itUsesDefaultFormatStringIfNoneSpecified() throws Exception { + public void itUsesDefaultFormatStringIfNoneSpecified() { assertThat(filter.filter(d, interpreter)).isEqualTo("14:22 / 06-11-2013"); } @Test - public void itUsesSpecifiedFormatString() throws Exception { - assertThat(filter.filter(d, interpreter, "%B %d, %Y, at %I:%M %p")).isEqualTo("November 06, 2013, at 02:22 PM"); + public void itUsesSpecifiedFormatString() { + assertThat(filter.filter(d, interpreter, "%B %d, %Y, at %I:%M %p")) + .isEqualTo("November 06, 2013, at 02:22 PM"); } @Test - public void itHandlesVarsAndLiterals() throws Exception { + public void itHandlesVarsAndLiterals() { interpreter.getContext().put("d", d); interpreter.getContext().put("foo", "%Y-%m"); - assertThat(interpreter.renderString("{{ d|datetimeformat(foo) }}")).isEqualTo("2013-11"); - assertThat(interpreter.renderString("{{ d|datetimeformat(\"%Y-%m-%d\") }}")).isEqualTo("2013-11-06"); - assertThat(interpreter.getErrors()).isEmpty(); + assertThat(interpreter.renderFlat("{{ d|datetimeformat(foo) }}")) + .isEqualTo("2013-11"); + assertThat(interpreter.renderFlat("{{ d|datetimeformat(\"%Y-%m-%d\") }}")) + .isEqualTo("2013-11-06"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itSupportsTimezones() { + assertThat(filter.filter(1539277785000L, interpreter, "%B %d, %Y, at %I:%M %p")) + .isEqualTo("October 11, 2018, at 05:09 PM"); + assertThat( + filter.filter( + 1539277785000L, + interpreter, + "%B %d, %Y, at %I:%M %p", + "America/New_York" + ) + ) + .isEqualTo("October 11, 2018, at 01:09 PM"); + assertThat( + filter.filter(1539277785000L, interpreter, "%B %d, %Y, at %I:%M %p", "UTC+8") + ) + .isEqualTo("October 12, 2018, at 01:09 AM"); + } + + @Test(expected = InvalidArgumentException.class) + public void itThrowsExceptionOnInvalidTimezone() { + filter.filter( + 1539277785000L, + interpreter, + "%B %d, %Y, at %I:%M %p", + "Not a timezone" + ); + } + + @Test(expected = InvalidDateFormatException.class) + public void itThrowsExceptionOnInvalidDateformat() { + filter.filter(1539277785000L, interpreter, "Not a format"); + } + + @Test + public void itConvertsDatetimesByLocales() { + interpreter.getContext().put("d", d); + + assertThat(interpreter.renderFlat("{{ d|datetimeformat('%A, %e %B', 'UTC', 'sv') }}")) + .isEqualTo("onsdag, 6 november"); + } + + @Test + public void itConvertsToLocaleSpecificDateTimeFormat() { + assertThat( + filter.filter( + 1539277785000L, + interpreter, + "%x %X - %c", + "America/New_York", + "en-US" + ) + ) + .isIn( + "10/11/18 1:09:45 PM - Oct 11, 2018, 1:09:45 PM", + "10/11/18 1:09:45 PM - Oct 11, 2018, 1:09:45 PM" + ); + assertThat( + filter.filter( + 1539277785000L, + interpreter, + "%x %X - %c", + "America/New_York", + "de-DE" + ) + ) + .isEqualTo("11.10.18 13:09:45 - 11.10.2018, 13:09:45"); } + @Test + public void itDefaultsToEnglishForBadLocaleValues() { + interpreter.getContext().put("d", d); + + assertThat( + interpreter.renderFlat("{{ d|datetimeformat('%A, %e %B', 'UTC', 'not_a_locale') }}") + ) + .isEqualTo(Functions.dateTimeFormat(d, "%A, %e %B", "UTC", "America/Los_Angeles")); + } + + @Test + public void itDefaultsToUtcForNullTimezone() { + interpreter.getContext().put("d", d); + + assertThat( + interpreter.renderFlat("{{ d|datetimeformat('%A, %e %B, %I:%M %p', null, 'sv') }}") + ) + .isEqualTo("onsdag, 6 november, 02:22 em"); + } + + @Test + public void itHandlesInvalidDateFormats() { + RenderResult result = jinjava.renderForResult( + "{{ d | datetimeformat('%é') }}", + ImmutableMap.of("d", d) + ); + + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + + TemplateError error = result.getErrors().get(0); + assertThat(error.getSeverity()).isEqualTo(ErrorType.FATAL); + assertThat(error.getMessage()) + .contains("Invalid date format '%é': unknown format code 'é'"); + } + + @Test + public void itUsesDeprecationDateIfNoDateProvided() { + ZonedDateTime now = ZonedDateTime.now(); + + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + FIXED_DATE_TIME_FILTER_NULL_ARG, + DateTimeFeatureActivationStrategy.of(now) + ) + .build() + ) + .build() + ); + + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + try { + assertThat(filter.filter(null, interpreter)) + .isEqualTo(StrftimeFormatter.format(now)); + assertThat(interpreter.getErrors().get(0).getMessage()) + .contains("datetimeformat filter called with null datetime"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DefaultFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DefaultFilterTest.java new file mode 100644 index 000000000..0faa5d4bc --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DefaultFilterTest.java @@ -0,0 +1,104 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +/** + * Created by manishdevgan on 25/06/19. + */ + +public class DefaultFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(DefaultFilter.class); + } + + @Test + public void itSetsDefaultStringValues() { + assertThat( + jinjava.render( + "{% set d=d | default(\"some random value\") %}{{ d }}", + new HashMap<>() + ) + ) + .isEqualTo("some random value"); + } + + @Test + public void itSetsDefaultObjectValue() { + assertThat( + jinjava.render( + "{% set d=d | default({\"key\": \"value\"}) %}Value = {{ d.key }}", + new HashMap<>() + ) + ) + .isEqualTo("Value = value"); + } + + @Test + public void itChecksForType() { + assertThat( + jinjava.render( + "{% set d=d | default({\"key\": \"value\"}) %}Type = {{ type(d.key) }}", + new HashMap<>() + ) + ) + .isEqualTo("Type = str"); + assertThat( + jinjava.render( + "{% set d=d | default(\"some random value\") %}{{ type(d) }}", + new HashMap<>() + ) + ) + .isEqualTo("str"); + } + + @Test + public void itCorrectlyProcessesNamedParameters() { + assertThat( + jinjava.render( + "{% set d=d | default(truthy=False, default_value={\"key\": \"value\"}) %}Type = {{ type(d.key) }}", + new HashMap<>() + ) + ) + .isEqualTo("Type = str"); + } + + @Test + public void itIgnoresBadTruthyValue() { + assertThat( + jinjava.render( + "{% set d=d | default({\"key\": \"value\"}, \"Blah\") %}Type = {{ type(d.key) }}", + new HashMap<>() + ) + ) + .isEqualTo("Type = str"); + } + + @Test + public void itDefaultsNullToNull() { + assertThat( + jinjava.render( + "{% set d=d | default(null) %}{% if (d == null) %}default yields real null{% else %}default yields something other than null{% endif %}", + new HashMap<>() + ) + ) + .isEqualTo("default yields real null"); + } + + @Test + public void itDefaultsNullToNullWithTruthyParam() { + assertThat( + jinjava.render( + "{% set d=d | default(null, true) %}{% if (d == null) %}default with truthy yields real null{% else %}default with truthy yields something other than null{% endif %}", + new HashMap<>() + ) + ) + .isEqualTo("default with truthy yields real null"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DictSortFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DictSortFilterTest.java new file mode 100644 index 000000000..37c7ad299 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DictSortFilterTest.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.BeforeClass; +import org.junit.Test; + +public class DictSortFilterTest extends BaseJinjavaTest { + + private static Map context; + + @BeforeClass + public static void initTemplate() { + context = new HashMap<>(); + + Map countryCapitals = new HashMap<>(); + countryCapitals.put("Bhutan", "Thimpu"); + countryCapitals.put("Australia", "Canberra"); + countryCapitals.put("none", "none"); + countryCapitals.put("India", "New Delhi"); + countryCapitals.put("France", "Paris"); + + context.put("countryCapitals", countryCapitals); + } + + @Test + public void sortByKeyCaseInsensitive() { + String template = + "{% for key, value in countryCapitals|dictsort %}" + + " {{key}},{{value}}" + + " {% endfor %}"; + + String expected = + " Australia,Canberra Bhutan,Thimpu France,Paris India,New Delhi none,none "; + + String actual = jinjava.render(template, context); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void sortByValueAndCaseInsensitive() { + String template = + "{% for key, value in countryCapitals|dictsort(false,'value') %}" + + " {{key}},{{value}}" + + " {% endfor %}"; + + String expected = + " Australia,Canberra India,New Delhi none,none France,Paris Bhutan,Thimpu "; + + String actual = jinjava.render(template, context); + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DifferenceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DifferenceFilterTest.java new file mode 100644 index 000000000..d5269d990 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DifferenceFilterTest.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class DifferenceFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itComputesSetDifferences() { + assertThat( + jinjava.render("{{ [1, 2, 3, 3, 4]|difference([1, 2, 5, 6]) }}", new HashMap<>()) + ) + .isEqualTo("[3, 4]"); + assertThat( + jinjava.render("{{ ['do', 'ray']|difference(['ray', 'me']) }}", new HashMap<>()) + ) + .isEqualTo("['do']"); + } + + @Test + public void itReturnsEmptyOnNullParameters() { + assertThat(jinjava.render("{{ [1, 2, 3, 3]|difference(null) }}", new HashMap<>())) + .isEqualTo("[1, 2, 3]"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/DivideFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/DivideFilterTest.java new file mode 100644 index 000000000..6ba1793b8 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/DivideFilterTest.java @@ -0,0 +1,78 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import org.junit.Test; + +public class DivideFilterTest extends BaseJinjavaTest { + + @Test + public void itDivides() { + assertThat( + jinjava.render( + "{{ numerator|divide(denominator) }}", + ImmutableMap.of("numerator", 10, "denominator", 2) + ) + ) + .isEqualTo("5"); + assertThat( + jinjava.render( + "{{ numerator // denominator }}", + ImmutableMap.of("numerator", 10, "denominator", 2) + ) + ) + .isEqualTo("5"); + assertThat( + jinjava.render( + "{{ numerator / denominator }}", + ImmutableMap.of("numerator", 10, "denominator", 2) + ) + ) + .isEqualTo("5.0"); + } + + @Test + public void itDividesIntegersWithNonIntegerResult() { + assertThat( + jinjava.render( + "{{ numerator|divide(denominator) }} {{ numerator / denominator }}", + ImmutableMap.of("numerator", 9, "denominator", 10) + ) + ) + .isEqualTo("1 0.9"); + } + + @Test + public void itRendersWithMorePrecisionWithConfigOption() { + Jinjava customJinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withEnablePreciseDivideFilter(true) + .build() + ); + + assertThat( + jinjava.render( + "{{ numerator|divide(denominator) }}", + ImmutableMap.of("numerator", 2, "denominator", 100) + ) + ) + .isEqualTo("0"); + + assertThat( + customJinjava.render( + "{{ numerator|divide(denominator) }}", + ImmutableMap.of("numerator", 2, "denominator", 100) + ) + ) + .isEqualTo("0.02"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/EscapeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeFilterTest.java index d1459c76c..8516c95d0 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/EscapeFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeFilterTest.java @@ -1,21 +1,24 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; +import com.google.common.base.Stopwatch; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.SafeString; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +public class EscapeFilterTest extends BaseInterpretingTest { -public class EscapeFilterTest { - - JinjavaInterpreter interpreter; EscapeFilter f; @Before public void setup() { - interpreter = mock(JinjavaInterpreter.class); f = new EscapeFilter(); } @@ -23,8 +26,57 @@ public void setup() { public void testEscape() { assertThat(f.filter("", interpreter)).isEqualTo(""); assertThat(f.filter("me & you", interpreter)).isEqualTo("me & you"); - assertThat(f.filter("jared's & ted's bogus journey", interpreter)).isEqualTo("jared's & ted's bogus journey"); + assertThat(f.filter("jared's & ted's bogus journey", interpreter)) + .isEqualTo("jared's & ted's bogus journey"); assertThat(f.filter(1, interpreter)).isEqualTo("1"); } + @Test + public void testSafeStringCanBeEscaped() { + assertThat( + f.filter(new SafeString("Previously marked as safe"), interpreter).toString() + ) + .isEqualTo("<a>Previously marked as safe<a/>"); + assertThat(f.filter(new SafeString("Previously marked as safe"), interpreter)) + .isInstanceOf(SafeString.class); + } + + @Ignore + public void testNewStringReplaceIsFaster() { + String html = fixture("filter/blog.html").substring(0, 100_000); + Stopwatch oldStopWatch = Stopwatch.createStarted(); + String oldResult = EscapeFilter.oldEscapeHtmlEntities(html); + Duration oldTime = oldStopWatch.elapsed(); + + Stopwatch newStopWatch = Stopwatch.createStarted(); + String newResult = EscapeFilter.escapeHtmlEntities(html); + Duration newTime = newStopWatch.elapsed(); + + assertThat(newResult).isEqualTo(oldResult); + System.out.printf("New: %d Old:%d\n", newTime.toMillis(), oldTime.toMillis()); + double speedUpFactor = getVersion() < 17 ? 1.5d : 1; // On M1, it is between 50 and 100 times faster. Difference is much smaller on java 17 + assertThat(newTime.toMillis()) + .isLessThan((long) (oldTime.toMillis() / speedUpFactor)); + } + + private static String fixture(String name) { + try { + return Resources.toString(Resources.getResource(name), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static int getVersion() { + String version = System.getProperty("java.version"); + if (version.startsWith("1.")) { + version = version.substring(2, 3); + } else { + int dotIndex = version.indexOf("."); + if (dotIndex != -1) { + version = version.substring(0, dotIndex); + } + } + return Integer.parseInt(version); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilterTest.java new file mode 100644 index 000000000..b5d6be5b6 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJinjavaFilterTest.java @@ -0,0 +1,44 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Before; +import org.junit.Test; + +public class EscapeJinjavaFilterTest extends BaseInterpretingTest { + + EscapeJinjavaFilter f; + + @Before + public void setup() { + f = new EscapeJinjavaFilter(); + } + + @Test + public void testEscape() { + assertThat(f.filter("", interpreter)).isEqualTo(""); + assertThat(f.filter("{{ me & you }}", interpreter)) + .isEqualTo("{{ me & you }}"); + } + + @Test + public void testSafeStringCanBeEscaped() { + assertThat(f.filter("", interpreter)).isEqualTo(""); + assertThat(f.filter(new SafeString("{{ me & you }}"), interpreter).toString()) + .isEqualTo("{{ me & you }}"); + assertThat(f.filter(new SafeString("{{ me & you }}"), interpreter)) + .isInstanceOf(SafeString.class); + } + + @Test + public void testDoesNotEscapeJson() { + assertThat( + f.filter("{'foo': 'bar', '{{{ foo }}}': '{% bar %}'}", interpreter, "false") + ) + .isEqualTo( + "{'foo': 'bar', '{{{ foo }}}': '{% bar %}'}" + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsFilterTest.java new file mode 100644 index 000000000..e10fd6283 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsFilterTest.java @@ -0,0 +1,59 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class EscapeJsFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void testHandlesUnicdoe() { + Map vars = ImmutableMap.of( + "string", + "A" + "\u00ea" + "\u00f1" + "\u00fc" + "C" + ); + assertThat(jinjava.render("{{ string|escapejs }}", vars)) + .isEqualTo("A\\u00EA\\u00F1\\u00FCC"); + } + + @Test + public void testHandlesNonPrintableCharacters() { + byte[] bytes = { 0x4D, 0x13, 0x34, 0x20, 0x8 }; + Map vars = ImmutableMap.of("string", new String(bytes)); + assertThat(jinjava.render("{{ string|escapejs }}", vars)).isEqualTo("M\\u00134 \\b"); + } + + @Test + public void testHandlesWhitespace() { + assertThat(jinjava.render("{{ 'Testing\nlinebreak\n'|escapejs }}", new HashMap<>())) + .isEqualTo("Testing\\nlinebreak\\n"); + assertThat(jinjava.render("{{ 'Testing\ttabbing\t'|escapejs }}", new HashMap<>())) + .isEqualTo("Testing\\ttabbing\\t"); + } + + @Test + public void testHandlesDoubleQuotes() { + assertThat( + jinjava.render("{{ 'Testing a \"quote for the week\"'|escapejs }}", new HashMap<>()) + ) + .isEqualTo("Testing a \\\"quote for the week\\\""); + } + + @Test + public void testSafeStringCanBeEscaped() { + assertThat( + jinjava.render("{{ 'Testing\nlineb\"reak\n'|safe|escapejs }}", new HashMap<>()) + ) + .isEqualTo("Testing\\nlineb\\\"reak\\n"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilterTest.java new file mode 100644 index 000000000..c4569eef9 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/EscapeJsonFilterTest.java @@ -0,0 +1,69 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class EscapeJsonFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsonFilter.class); + } + + @Test + public void testHandlesUnicode() { + Map vars = ImmutableMap.of("string", "A" + "\"" + "\\" + "/"); + assertThat(jinjava.render("{{ string|escapejson }}", vars)).isEqualTo("A\\\"\\\\\\/"); + } + + @Test + public void testHandlesNonPrintableCharacters() { + byte[] bytes = { 0x4D, 0x13, 0x34, 0x20, 0x8 }; + Map vars = ImmutableMap.of("string", new String(bytes)); + assertThat(jinjava.render("{{ string|escapejson }}", vars)) + .isEqualTo("M\\u00134 \\b"); + } + + @Test + public void testHandlesWhitespace() { + assertThat(jinjava.render("{{ 'Testing\nlinebreak\n'|escapejson }}", new HashMap<>())) + .isEqualTo("Testing\\nlinebreak\\n"); + assertThat(jinjava.render("{{ 'Testing\ttabbing\t'|escapejson }}", new HashMap<>())) + .isEqualTo("Testing\\ttabbing\\t"); + } + + @Test + public void testHandlesDoubleQuotes() { + assertThat( + jinjava.render( + "{{ 'Testing a \"quote for the week\"'|escapejson }}", + new HashMap<>() + ) + ) + .isEqualTo("Testing a \\\"quote for the week\\\""); + } + + @Test + public void testHandleSingleQuote() { + Map vars = ImmutableMap.of( + "string", + "Testing a 'single quote' for the week" + ); + assertThat(jinjava.render("{{ string|escapejson }}", vars)) + .isEqualTo("Testing a 'single quote' for the week"); + } + + @Test + public void testSafeStringCanBeEscaped() { + assertThat( + jinjava.render("{{ 'Testing\nlineb\"reak\n'|safe|escapejs }}", new HashMap<>()) + ) + .isEqualTo("Testing\\nlineb\\\"reak\\n"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilterTest.java index 08893a716..58449a4b5 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FileSizeFormatFilterTest.java @@ -2,30 +2,36 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; import java.util.HashMap; import java.util.Locale; - import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; - -public class FileSizeFormatFilterTest { - - Jinjava jinjava; +public class FileSizeFormatFilterTest extends BaseJinjavaTest { @Before public void setup() { Locale.setDefault(Locale.ENGLISH); - jinjava = new Jinjava(); } @Test public void testFileSizeFormatFilter() { - assertThat(jinjava.render("{{12|filesizeformat}}", new HashMap())).isEqualTo("12 Bytes"); - assertThat(jinjava.render("{{1000|filesizeformat}}", new HashMap())).isEqualTo("1.0 KB"); - assertThat(jinjava.render("{{1024|filesizeformat(true)}}", new HashMap())).isEqualTo("1.0 KiB"); - assertThat(jinjava.render("{{3531836|filesizeformat(true)}}", new HashMap())).isEqualTo("3.4 MiB"); + assertThat(jinjava.render("{{12|filesizeformat}}", new HashMap())) + .isEqualTo("12 Bytes"); + assertThat(jinjava.render("{{1000|filesizeformat}}", new HashMap())) + .isEqualTo("1.0 KB"); + assertThat( + jinjava.render("{{1024|filesizeformat(true)}}", new HashMap()) + ) + .isEqualTo("1.0 KiB"); + assertThat( + jinjava.render("{{3531836|filesizeformat(true)}}", new HashMap()) + ) + .isEqualTo("3.4 MiB"); + assertThat( + jinjava.render("{{1000000000|filesizeformat}}", new HashMap()) + ) + .isEqualTo("1.0 GB"); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FirstFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FirstFilterTest.java index a6965deea..badd01a44 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/FirstFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FirstFilterTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Arrays; - import org.junit.Before; import org.junit.Test; @@ -25,5 +24,4 @@ public void firstReturnsNullForEmptyList() { public void firstForSeq() { assertThat(filter.filter(Arrays.asList("foo", "bar"), null)).isEqualTo("foo"); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FloatFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FloatFilterTest.java new file mode 100644 index 000000000..7f6e759fd --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FloatFilterTest.java @@ -0,0 +1,131 @@ +/********************************************************************** + Copyright (c) 2018 HubSpot 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.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormatSymbols; +import java.time.ZoneOffset; +import java.util.Locale; +import org.junit.Before; +import org.junit.Test; + +public class FloatFilterTest extends BaseInterpretingTest { + + private static final Locale FRENCH_LOCALE = new Locale("fr", "FR"); + private static final JinjavaConfig FRENCH_LOCALE_CONFIG = BaseJinjavaTest + .newConfigBuilder() + .withCharset(StandardCharsets.UTF_8) + .withLocale(FRENCH_LOCALE) + .withTimeZone(ZoneOffset.UTC) + .build(); + + FloatFilter filter; + + @Before + public void setup() { + filter = new FloatFilter(); + } + + @Test + public void itReturnsSameWhenVarIsNumber() { + Float var = 123.4f; + assertThat(filter.filter(var, interpreter)).isSameAs(var); + } + + @Test + public void itReturnsDefaultWhenVarIsNull() { + assertThat(filter.filter(null, interpreter)).isEqualTo(0.0f); + assertThat(filter.filter(null, interpreter, "123.45")).isEqualTo(123.45f); + } + + @Test + public void itIgnoresGivenDefaultIfNaN() { + assertThat(filter.filter(null, interpreter, "foo")).isEqualTo(0.0f); + } + + @Test + public void itReturnsVarAsFloat() { + assertThat(filter.filter("123.45", interpreter)).isEqualTo(123.45f); + assertThat(filter.filter("1.100000", interpreter)).isEqualTo(1.100000f); + } + + @Test + public void itReturnsVarWithTrailingPercentAsDefault() { + assertThat(filter.filter("123%", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itReturnsVarWithLeadingLettersAsDefault() { + assertThat(filter.filter("abc123", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itReturnsVarWithTrailingLettersAsDefault() { + assertThat(filter.filter("123abc", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itReturnsVarWithLeadingCurrencySymbolAsDefault() { + assertThat(filter.filter("$123", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itReturnsVarWithTrailingCurrencySymbolAsDefault() { + assertThat(filter.filter("123$", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itInterpretsUsCommasAndPeriodsWithUsLocale() { + assertThat(filter.filter("123,123.45", interpreter)).isEqualTo(123123.45f); + } + + @Test + public void itDoesntInterpretFrenchCommasAndPeriodsWithUsLocale() { + assertThat(filter.filter("123.123,45", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itReturnsDefaultWhenUnableToParseVar() { + assertThat(filter.filter("foo", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itDoesntInterpretUsCommasAndPeriodsWithFrenchLocale() { + interpreter = new Jinjava(FRENCH_LOCALE_CONFIG).newInterpreter(); + assertThat(filter.filter("123,123.12", interpreter)).isEqualTo(0.0f); + } + + @Test + public void itInterpretsFrenchCommasAndPeriodsWithFrenchLocale() { + interpreter = new Jinjava(FRENCH_LOCALE_CONFIG).newInterpreter(); + assertThat( + filter.filter( + String.format( + "123%c123,45", + DecimalFormatSymbols.getInstance(Locale.FRENCH).getGroupingSeparator() + ), + interpreter + ) + ) + .isEqualTo(123123.45f); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FormatFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FormatFilterTest.java index 8a5bddcac..c7e156ab4 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/FormatFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FormatFilterTest.java @@ -1,26 +1,57 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; import java.util.HashMap; - -import org.junit.Before; +import java.util.Locale; +import org.junit.BeforeClass; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; +public class FormatFilterTest extends BaseJinjavaTest { + + @BeforeClass + public static void beforeClass() { + Locale.setDefault(Locale.ENGLISH); + } -public class FormatFilterTest { + @Test + public void testFormatFilter() { + assertThat( + jinjava.render("{{ '%s - %s'|format(\"Hello?\", \"Foo!\") }}", new HashMap<>()) + ) + .isEqualTo("Hello? - Foo!"); + } - Jinjava jinjava; + @Test + public void testFormatNumber() { + assertThat(jinjava.render("{{ '%,d'|format(10000) }}", new HashMap<>())) + .isEqualTo("10,000"); + } - @Before - public void setup() { - jinjava = new Jinjava(); + @Test + public void itThrowsExceptionOnMissingFormatArgument() { + assertThatThrownBy(() -> + jinjava.render("{{ '%s %s'|format(10000) }}", new HashMap<>()) + ) + .isInstanceOf(FatalTemplateErrorsException.class) + .hasMessageContaining("Missing format argument"); } @Test - public void testFormatFilter() { - assertThat(jinjava.render("{{ '%s - %s'|format(\"Hello?\", \"Foo!\") }}", new HashMap())).isEqualTo("Hello? - Foo!"); + public void itThrowsExceptionOnBadConversion() { + assertThatThrownBy(() -> jinjava.render("{{ '%d'|format('hi') }}", new HashMap<>())) + .isInstanceOf(FatalTemplateErrorsException.class) + .hasMessageContaining("is not a compatible type"); } + @Test + public void itThrowsExceptionOnFormat() { + assertThatThrownBy(() -> jinjava.render("{{ '%0.0f'|format(1000) }}", new HashMap<>()) + ) + .isInstanceOf(FatalTemplateErrorsException.class) + .hasMessageContaining("'%0.0f' is missing a width"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FormatNumberFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FormatNumberFilterTest.java new file mode 100644 index 000000000..aff5df24b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FormatNumberFilterTest.java @@ -0,0 +1,85 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.text.DecimalFormatSymbols; +import java.util.HashMap; +import java.util.Locale; +import org.junit.Before; +import org.junit.Test; + +public class FormatNumberFilterTest extends BaseJinjavaTest { + + @Before + public void setup() {} + + @Test + public void testFormatNumberFilter() { + assertThat( + jinjava.render("{{1000|format_number('en-US')}}", new HashMap()) + ) + .isEqualTo("1,000"); + assertThat( + jinjava.render( + "{{ 1000.333|format_number('en-US') }}", + new HashMap() + ) + ) + .isEqualTo("1,000.333"); + assertThat( + jinjava.render( + "{{ 1000.333|format_number('en-US', 2) }}", + new HashMap() + ) + ) + .isEqualTo("1,000.33"); + + assertThat( + jinjava.render("{{ 1000|format_number('fr') }}", new HashMap()) + ) + .isEqualTo( + String.format( + "1%s000", + DecimalFormatSymbols.getInstance(Locale.FRENCH).getGroupingSeparator() + ) + ); + assertThat( + jinjava.render("{{ 1000.333|format_number('fr') }}", new HashMap()) + ) + .isEqualTo( + String.format( + "1%s000,333", + DecimalFormatSymbols.getInstance(Locale.FRENCH).getGroupingSeparator() + ) + ); + assertThat( + jinjava.render( + "{{ 1000.333|format_number('fr', 2) }}", + new HashMap() + ) + ) + .isEqualTo( + String.format( + "1%s000,33", + DecimalFormatSymbols.getInstance(Locale.FRENCH).getGroupingSeparator() + ) + ); + + assertThat( + jinjava.render("{{ 1000|format_number('es') }}", new HashMap()) + ) + .isEqualTo("1.000"); + assertThat( + jinjava.render("{{ 1000.333|format_number('es') }}", new HashMap()) + ) + .isEqualTo("1.000,333"); + assertThat( + jinjava.render( + "{{ 1000.333|format_number('es', 2) }}", + new HashMap() + ) + ) + .isEqualTo("1.000,33"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FromJsonFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FromJsonFilterTest.java new file mode 100644 index 000000000..d8d337936 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FromJsonFilterTest.java @@ -0,0 +1,99 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.InvalidInputException; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class FromJsonFilterTest extends BaseInterpretingTest { + + private FromJsonFilter filter; + + @Before + public void setup() { + filter = new FromJsonFilter(); + } + + @Test(expected = InvalidInputException.class) + public void itFailsWhenStringIsNotJson() { + String json = "blah"; + + filter.filter(json, interpreter); + } + + @Test(expected = InvalidInputException.class) + public void itFailsWhenParameterIsNotString() { + Integer json = 456; + + filter.filter(json, interpreter); + } + + @Test(expected = InvalidInputException.class) + public void itFailsWhenJsonIsInvalid() { + String json = "{[ }]"; + + filter.filter(json, interpreter); + } + + @Test + public void itRendersTrivialJsonObject() { + String trivialJsonObject = "{\"a\":100,\"b\":200}"; + + Map vars = ImmutableMap.of("test", trivialJsonObject); + String template = "{% set obj = test | fromjson %}{{ obj.a }} {{ obj.b }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("100 200"); + } + + @Test + public void itRendersTrivialJsonArray() { + String trivialJsonArray = "[\"one\",\"two\",\"three\"]"; + + Map vars = ImmutableMap.of("test", trivialJsonArray); + String template = + "{% set obj = test | fromjson %}{{ obj[0] }} {{ obj[1] }} {{ obj[2] }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("one two three"); + } + + @Test + public void itRendersNestedObjectJson() { + String nestedObject = "{\"first\": 1,\"nested\":{\"second\":\"string\",\"third\":4}}"; + + Map vars = ImmutableMap.of("test", nestedObject); + String template = + "{% set obj = test | fromjson %}{{ obj.first }} {{ obj.nested.second }} {{ obj.nested.third }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("1 string 4"); + } + + @Test + public void itRendersNestedJsonWithArray() { + String nestedObjectWithArray = "{\"a\":{\"b\":{\"c\":[1,2,3]}}}"; + + Map vars = ImmutableMap.of("test", nestedObjectWithArray); + String template = "{% set obj = test | fromjson %}{{ obj.a.b.c }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("[1, 2, 3]"); + } + + @Test + public void itRendersArrayOfObjects() { + String arrayOfObjects = "[{\"a\":1},{\"a\":2},{\"a\": 3}]"; + + Map vars = ImmutableMap.of("test", arrayOfObjects); + String template = + "{% set obj = test | fromjson %}{{ obj[0].a }} {{ obj[1].a }} {{ obj[2].a }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("1 2 3"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/FromYamlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/FromYamlFilterTest.java new file mode 100644 index 000000000..f7ff45bc5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/FromYamlFilterTest.java @@ -0,0 +1,94 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.InvalidInputException; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class FromYamlFilterTest extends BaseInterpretingTest { + + private FromYamlFilter filter; + + @Before + public void setup() { + filter = new FromYamlFilter(); + } + + @Test(expected = InvalidInputException.class) + public void itFailsWhenParameterIsNotString() { + Integer json = 456; + + filter.filter(json, interpreter); + } + + @Test(expected = InvalidInputException.class) + public void itFailsWhenYamlIsInvalid() { + String json = "a: b:"; + + filter.filter(json, interpreter); + } + + @Test + public void itRendersTrivialYamlObject() { + String trivialYamlObject = "a: 100\n" + "b: 200"; + + Map vars = ImmutableMap.of("test", trivialYamlObject); + String template = "{% set obj = test | fromyaml %}{{ obj.a }} {{ obj.b }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("100 200"); + } + + @Test + public void itRendersTrivialYamlArray() { + String trivialYamlArray = "- one\n" + "- two\n" + "- three"; + + Map vars = ImmutableMap.of("test", trivialYamlArray); + String template = + "{% set obj = test | fromyaml %}{{ obj[0] }} {{ obj[1] }} {{ obj[2] }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("one two three"); + } + + @Test + public void itRendersNestedObjectYaml() { + String nestedObject = + "first: 1\n" + "nested:\n" + " second: string\n" + " third: 4"; + + Map vars = ImmutableMap.of("test", nestedObject); + String template = + "{% set obj = test | fromyaml %}{{ obj.first }} {{ obj.nested.second }} {{ obj.nested.third }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("1 string 4"); + } + + @Test + public void itRendersNestedYamlWithArray() { + String nestedObjectWithArray = + "a:\n" + " b:\n" + " c:\n" + " - 1\n" + " - 2\n" + " - 3"; + + Map vars = ImmutableMap.of("test", nestedObjectWithArray); + String template = "{% set obj = test | fromyaml %}{{ obj.a.b.c }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("[1, 2, 3]"); + } + + @Test + public void itRendersArrayOfObjects() { + String arrayOfObjects = "- a: 1\n" + "- a: 2\n" + "- a: 3"; + + Map vars = ImmutableMap.of("test", arrayOfObjects); + String template = + "{% set obj = test | fromyaml %}{{ obj[0].a }} {{ obj[1].a }} {{ obj[2].a }}"; + String renderedJinjava = jinjava.render(template, vars); + + assertThat(renderedJinjava).isEqualTo("1 2 3"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/GroupByFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/GroupByFilterTest.java index d6e9dc62d..6d65ebf4d 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/GroupByFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/GroupByFilterTest.java @@ -2,46 +2,46 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; import java.nio.charset.StandardCharsets; - import org.jsoup.Jsoup; import org.jsoup.nodes.Document; -import org.junit.Before; import org.junit.Test; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; - -public class GroupByFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class GroupByFilterTest extends BaseJinjavaTest { @Test public void testGroupByAttr() throws Exception { Document dom = Jsoup.parseBodyFragment( - jinjava.render( - Resources.toString(Resources.getResource("filter/groupby-attr.jinja"), StandardCharsets.UTF_8), - ImmutableMap.of("persons", (Object) Lists.newArrayList( - new Person("male", "jared", "stehler"), - new Person("male", "foo", "bar"), - new Person("female", "sarah", "jones"), - new Person("male", "jim", "jones"), - new Person("female", "barb", "smith") - )))); + jinjava.render( + Resources.toString( + Resources.getResource("filter/groupby-attr.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of( + "persons", + (Object) Lists.newArrayList( + new Person("male", "jared", "stehler"), + new Person("male", "foo", "bar"), + new Person("female", "sarah", "jones"), + new Person("male", "jim", "jones"), + new Person("female", "barb", "smith") + ) + ) + ) + ); assertThat(dom.select("ul.root > li")).hasSize(2); + assertThat(dom.select("ul.root > li").get(0).text()).contains("male jared"); assertThat(dom.select("ul.root > li.male > ul > li")).hasSize(3); assertThat(dom.select("ul.root > li.female > ul > li")).hasSize(2); } public static class Person { + private String gender; private String firstName; private String lastName; @@ -64,5 +64,4 @@ public String getLastName() { return lastName; } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/IndentFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/IndentFilterTest.java new file mode 100644 index 000000000..1c9bcac8d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/IndentFilterTest.java @@ -0,0 +1,38 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class IndentFilterTest extends BaseJinjavaTest { + + private final Map VARS = new HashMap<>(); + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(IndentFilter.class); + + VARS.put("multiLine", "1\n2\n3"); + } + + @Test + public void itDoesntIndentFirstlineByDefault() { + assertThat(jinjava.render("{% set d=multiLine | indent %}{{ d }}", VARS)) + .isEqualTo("1\n" + " 2\n" + " 3"); + } + + @Test + public void itIndentsFirstline() { + assertThat( + jinjava.render( + "{% set d=multiLine | indent(indentfirst= True, width=1) %}{{ d }}", + VARS + ) + ) + .isEqualTo(" 1\n" + " 2\n" + " 3"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/IntFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/IntFilterTest.java index 62fd7f55f..b12ad01e2 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/IntFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/IntFilterTest.java @@ -2,26 +2,37 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormatSymbols; +import java.time.ZoneOffset; +import java.util.Locale; import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +public class IntFilterTest extends BaseInterpretingTest { -public class IntFilterTest { + private static final Locale FRENCH_LOCALE = new Locale("fr", "FR"); + private static final JinjavaConfig FRENCH_LOCALE_CONFIG = BaseJinjavaTest + .newConfigBuilder() + .withCharset(StandardCharsets.UTF_8) + .withLocale(FRENCH_LOCALE) + .withTimeZone(ZoneOffset.UTC) + .build(); IntFilter filter; - JinjavaInterpreter interpreter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); filter = new IntFilter(); } @Test public void itReturnsSameWhenVarIsNumber() { - Integer var = Integer.valueOf(123); + Integer var = 123; assertThat(filter.filter(var, interpreter)).isSameAs(var); } @@ -38,7 +49,54 @@ public void itIgnoresGivenDefaultIfNaN() { @Test public void itReturnsVarAsInt() { - assertThat(filter.filter("123", interpreter)).isEqualTo(123); + assertThat(filter.filter("123", interpreter)) + .isInstanceOf(Integer.class) + .isEqualTo(123); + } + + @Test + public void itReturnsVarWithFloatingPointAsInt() { + assertThat(filter.filter("123.1", interpreter)).isEqualTo(123); + } + + @Test + public void itReturnsVarWithLeadingPercentAsDefault() { + assertThat(filter.filter("%60", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsVarWithTrailingPercentAsDefault() { + assertThat(filter.filter("60%", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsVarWithLeadingLettersAsDefault() { + assertThat(filter.filter("abc60", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsVarWithTrailingLettersAsDefault() { + assertThat(filter.filter("60abc", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsVarWithLeadingCurrencySymbolAsDefault() { + assertThat(filter.filter("$60", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsVarWithTrailingCurrencySymbolAsDefault() { + assertThat(filter.filter("60$", interpreter)).isEqualTo(0); + } + + @Test + public void itInterpretsUsCommasAndPeriodsWithUsLocale() { + assertThat(filter.filter("123,123.12", interpreter)).isEqualTo(123123); + } + + @Test + public void itDoesntInterpretFrenchCommasAndPeriodsWithUsLocale() { + assertThat(filter.filter("123.123,12", interpreter)).isEqualTo(0); } @Test @@ -46,4 +104,52 @@ public void itReturnsDefaultWhenUnableToParseVar() { assertThat(filter.filter("foo", interpreter)).isEqualTo(0); } + @Test + public void itDoesntInterpretUsCommasAndPeriodsWithFrenchLocale() { + interpreter = new Jinjava(FRENCH_LOCALE_CONFIG).newInterpreter(); + assertThat(filter.filter("123,123.12", interpreter)).isEqualTo(0); + } + + @Test + public void itInterpretsFrenchCommasAndPeriodsWithFrenchLocale() { + interpreter = new Jinjava(FRENCH_LOCALE_CONFIG).newInterpreter(); + assertThat( + filter.filter( + String.format( + "123%c123,12", + DecimalFormatSymbols.getInstance(Locale.FRENCH).getGroupingSeparator() + ), + interpreter + ) + ) + .isEqualTo(123123); + } + + @Test + public void itUsesLongsForLargeValues() { + assertThat(filter.filter("1000000000001", interpreter)).isEqualTo(1000000000001L); + } + + @Test + public void itUsesLongsForLargeValueDefaults() { + assertThat(filter.filter("not a number", interpreter, "1000000000001")) + .isEqualTo(1000000000001L); + } + + @Test + public void itUsesLongsForVerySmallValues() { + assertThat(filter.filter("-42595200000", interpreter)).isEqualTo(-42595200000L); + } + + @Test + public void itConvertsProperlyInExpressionTest() { + assertThat(interpreter.render("{{ '3'|int in [null, 4, 5, 6, null, 3] }}")) + .isEqualTo("true"); + } + + @Test + public void itConvertsProperlyInExpressionTestWithWrongType() { + assertThat(interpreter.render("{{ 'test' in [null, 4, 5, 6, null, 3] }}")) + .isEqualTo("false"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/IntersectFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/IntersectFilterTest.java new file mode 100644 index 000000000..a7cb02c5e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/IntersectFilterTest.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class IntersectFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itComputesSetIntersections() { + assertThat( + jinjava.render("{{ [1, 1, 2, 3]|intersect([1, 2, 5, 6]) }}", new HashMap<>()) + ) + .isEqualTo("[1, 2]"); + assertThat( + jinjava.render("{{ ['do', 'ray']|intersect(['ray', 'me']) }}", new HashMap<>()) + ) + .isEqualTo("['ray']"); + } + + @Test + public void itReturnsEmptyOnNullParameters() { + assertThat(jinjava.render("{{ [1, 2, 3]|intersect(null) }}", new HashMap<>())) + .isEqualTo("[]"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java new file mode 100644 index 000000000..639e113af --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java @@ -0,0 +1,460 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.SafeString; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Before; +import org.junit.Test; + +public class IpAddrFilterTest extends BaseInterpretingTest { + + private IpAddrFilter ipAddrFilter; + private Ipv4Filter ipv4Filter; + private Ipv6Filter ipv6Filter; + + @Before + public void setup() { + ipAddrFilter = new IpAddrFilter(); + ipv4Filter = new Ipv4Filter(); + ipv6Filter = new Ipv6Filter(); + } + + @Test + public void itAcceptsValidIpV4Address() { + assertThat(ipAddrFilter.filter("255.182.100.1", interpreter)).isEqualTo(true); + assertThat(ipAddrFilter.filter("125.0.100.1", interpreter)).isEqualTo(true); + assertThat(ipAddrFilter.filter("128.0.0.1", interpreter)).isEqualTo(true); + assertThat(ipAddrFilter.filter(" 128.0.0.1 ", interpreter)).isEqualTo(true); + } + + @Test + public void itRejectsInvalidIpV4Address() { + assertThat(ipAddrFilter.filter("255.182.100.abc", interpreter)).isEqualTo(false); + assertThat(ipAddrFilter.filter("125.512.100.1", interpreter)).isEqualTo(false); + assertThat(ipAddrFilter.filter("125.512.100.1.1", interpreter)).isEqualTo(false); + assertThat(ipAddrFilter.filter("125.512.100", interpreter)).isEqualTo(false); + assertThat(ipAddrFilter.filter(104, interpreter)).isEqualTo(false); + } + + @Test + public void itAcceptsValidIpV6Address() { + assertThat( + ipAddrFilter.filter("1200:0000:AB00:1234:0000:2552:7777:1313", interpreter) + ) + .isEqualTo(true); + assertThat(ipAddrFilter.filter("21DA:D3:0:2F3B:2AA:FF:FE28:9C5A", interpreter)) + .isEqualTo(true); + assertThat(ipAddrFilter.filter("2000::", interpreter)).isEqualTo(true); + } + + @Test + public void itRejectsInvalidIpV6Address() { + assertThat(ipAddrFilter.filter("1200::AB00:1234::2552:7777:1313", interpreter)) + .isEqualTo(false); + assertThat( + ipAddrFilter.filter("1200:0000:AB00:1234:O000:2552:7777:1313", interpreter) + ) + .isEqualTo(false); + assertThat(ipAddrFilter.filter("1200::AB00:1234::2552:7777:1313:1232", interpreter)) + .isEqualTo(false); + assertThat(ipAddrFilter.filter("321", interpreter)).isEqualTo(false); + } + + @Test + public void itReturnsIpv4AddressPrefix() { + assertThat(ipAddrFilter.filter("255.182.100.1/24", interpreter, "prefix")) + .isEqualTo(24); + } + + @Test + public void itReturnsIpv6AddressPrefix() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "prefix" + ) + ) + .isEqualTo(43); + } + + @Test + public void itRejectsInvalidIpAddressPrefix() { + assertThat(ipAddrFilter.filter("255.182.100.abc/24", interpreter, "prefix")) + .isEqualTo(null); + } + + @Test + public void itReturnsIpv4AddressNetMask() { + assertThat(ipAddrFilter.filter("255.182.100.1/10", interpreter, "netmask")) + .isEqualTo("255.192.0.0"); + } + + @Test + public void itReturnsIpv6AddressNetMask() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "netmask" + ) + ) + .isEqualTo("ffff:ffff:ffe0::"); + } + + @Test + public void itReturnsIpv4AddressBroadcast() { + assertThat(ipAddrFilter.filter("192.168.0.1/20", interpreter, "broadcast")) + .isEqualTo("192.168.15.255"); + } + + @Test + public void itReturnsIpv6AddressBroadcast() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "broadcast" + ) + ) + .isEqualTo("1200:0:ab1f:ffff:ffff:ffff:ffff:ffff"); + } + + @Test + public void itReturnsIpv4AddressAddress() { + assertThat(ipAddrFilter.filter("192.168.0.1/20", interpreter, "address")) + .isEqualTo("192.168.0.1"); + assertThat(ipAddrFilter.filter("192.168.0.2", interpreter, "address")) + .isEqualTo("192.168.0.2"); + } + + @Test + public void itReturnsIpv6AddressAddress() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "address" + ) + ) + .isEqualTo("1200:0000:AB00:1234:0000:2552:7777:1313"); + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1314", + interpreter, + "address" + ) + ) + .isEqualTo("1200:0000:AB00:1234:0000:2552:7777:1314"); + } + + @Test + public void itReturnsIpv4AddressNetwork() { + assertThat(ipAddrFilter.filter("192.168.0.1/20", interpreter, "network")) + .isEqualTo("192.168.0.0"); + } + + @Test + public void itReturnsIpv6AddressNetwork() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "network" + ) + ) + .isEqualTo("1200:0:ab00::"); + } + + @Test + public void itReturnsIpv4AddressGateway() { + assertThat(ipAddrFilter.filter("192.168.0.10/20", interpreter, "gateway")) + .isEqualTo("192.168.0.1"); + } + + @Test + public void itReturnsIpv6AddressGateway() { + assertThat( + ipAddrFilter.filter( + "1200:0000:AB00:1234:0000:2552:7777:1313/43", + interpreter, + "gateway" + ) + ) + .isEqualTo("1200:0:ab00::1"); + } + + @Test + public void itAddsErrorOnInvalidCidrAddress() { + assertThatThrownBy(() -> + ipAddrFilter.filter("192.168.0.1/200", interpreter, "broadcast") + ) + .hasMessageContaining("must be a valid CIDR address"); + } + + @Test + public void itAddsErrorOnInvalidFunctionName() { + assertThatThrownBy(() -> + ipAddrFilter.filter("192.168.0.1/20", interpreter, "notAFunction") + ) + .hasMessageContaining("must be one of"); + } + + @Test + public void itFiltersValidIpv4Addresses() { + List validAddresses = Arrays.asList("255.182.100.1", " 128.0.0.1 "); + List invalidAddresses = Arrays.asList( + "255.182.100.abc", + "125.512.100.1", + "125.512.100.1.1", + "125.512.100", + 104, + "1200:0000:AB00:1234:0000:2552:7777:1313", + "1200::AB00:1234::2552:7777:1313", + "2000::", + "321", + null, + true + ); + List allAddresses = Stream + .concat(validAddresses.stream(), invalidAddresses.stream()) + .collect(Collectors.toList()); + assertThat(ipv4Filter.filter(allAddresses, interpreter)).isEqualTo(validAddresses); + } + + @Test + public void itFiltersValidIpv6Addresses() { + List validAddresses = Arrays.asList( + "1200:0000:AB00:1234:0000:2552:7777:1313", + "2000::" + ); + List invalidAddresses = Arrays.asList( + "255.182.100.abc", + " 128.0.0.1 ", + "125.0.100.1", + "125.512.100", + 104, + "1200:0000:AB00:1234:O000:2552:7777:1313", + "1200::AB00:1234::2552:7777:1313:1232", + "321", + null, + true + ); + List allAddresses = Stream + .concat(validAddresses.stream(), invalidAddresses.stream()) + .collect(Collectors.toList()); + assertThat(ipv6Filter.filter(allAddresses, interpreter)).isEqualTo(validAddresses); + } + + @Test + public void itFiltersIpAddressesInMap() { + Map validAddresses = new HashMap<>(); + validAddresses.put(1, "192.24.2.1"); + validAddresses.put(3, "192.168.32.0"); + validAddresses.put(6, "fe80::100"); + Map invalidAddresses = new HashMap<>(); + invalidAddresses.put(2, null); + invalidAddresses.put(4, 13); + invalidAddresses.put(5, true); + + Map allAddresses = new HashMap<>(validAddresses); + allAddresses.putAll(invalidAddresses); + assertThat(ipAddrFilter.filter(allAddresses, interpreter)).isEqualTo(validAddresses); + } + + @Test + public void itFiltersIpAddressesAddress() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + "", + null, + 13 + ); + List expectedAddresses = Arrays.asList( + "192.24.2.1", + "::1", + "192.168.32.0", + "fe80::100" + ); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "address")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesPrefix() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + "", + null, + 13 + ); + List expectedAddresses = Arrays.asList("24", "10"); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "prefix")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesNetwork() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13 + ); + List expectedAddresses = Arrays.asList("192.168.32.0", "fe80::"); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "network")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesGateway() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13 + ); + List expectedAddresses = Arrays.asList("192.168.32.1", "fe80::1"); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "gateway")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesNetmask() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13 + ); + List expectedAddresses = Arrays.asList("255.255.255.0", "ffc0::"); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "netmask")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesWithParameterInMap() { + Map inputAddresses = new HashMap<>(); + inputAddresses.put(1, "192.24.2.1"); + inputAddresses.put(2, null); + inputAddresses.put(3, "192.168.32.0/24"); + inputAddresses.put(4, 13); + inputAddresses.put(5, true); + inputAddresses.put(6, "fe80::100/10"); + + Map expectedAddresses = new HashMap<>(); + expectedAddresses.put(3, "255.255.255.0"); + expectedAddresses.put(6, "ffc0::"); + + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "netmask")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesBroadcast() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "::1", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13 + ); + List expectedAddresses = Arrays.asList( + "192.168.32.255", + "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff" + ); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "broadcast")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itWorksWithSafeString() throws Exception { + assertThat( + ipAddrFilter.filter( + new SafeString("1200:0000:AB00:1234:0000:2552:7777:1313"), + interpreter + ) + ) + .isEqualTo(true); + assertThat(ipAddrFilter.filter(new SafeString("255.182.100.abc"), interpreter)) + .isEqualTo(false); + assertThat(ipAddrFilter.filter(new SafeString(" 128.0.0.1 "), interpreter)) + .isEqualTo(true); + assertThat( + ipAddrFilter.filter(new SafeString("255.182.100.1/10"), interpreter, "netmask") + ) + .isEqualTo("255.192.0.0"); + } + + @Test + public void itFiltersIpAddressesPublic() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13, + "", + "2001:db8:32c:faad::/64" + ); + List expectedAddresses = Arrays.asList( + "192.24.2.1", + "2001:db8:32c:faad::/64" + ); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "public")) + .isEqualTo(expectedAddresses); + } + + @Test + public void itFiltersIpAddressesPrivate() { + List inputAddresses = Arrays.asList( + "192.24.2.1", + "host.fqdn", + "192.168.32.0/24", + "fe80::100/10", + true, + null, + 13, + "", + "2001:db8:32c:faad::/64" + ); + List expectedAddresses = Arrays.asList("192.168.32.0/24", "fe80::100/10"); + assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "private")) + .isEqualTo(expectedAddresses); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/JoinFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/JoinFilterTest.java index 2c8c2a335..3ef8f7db0 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/JoinFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/JoinFilterTest.java @@ -2,37 +2,55 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.RenderResult; import java.util.HashMap; - import org.junit.Before; import org.junit.Test; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; - -public class JoinFilterTest { - - Jinjava jinjava; +public class JoinFilterTest extends BaseJinjavaTest { @Before public void setup() { - jinjava = new Jinjava(); - jinjava.getGlobalContext().put("users", Lists.newArrayList( - new User("foo"), new User("bar"))); + jinjava + .getGlobalContext() + .put("users", Lists.newArrayList(new User("foo"), new User("bar"))); } @Test public void testJoinVals() { - assertThat(jinjava.render("{{ [1, 2, 3]|join('|') }}", new HashMap())).isEqualTo("1|2|3"); + assertThat(jinjava.render("{{ [1, 2, 3]|join('|') }}", new HashMap<>())) + .isEqualTo("1|2|3"); } @Test public void testJoinAttrs() { - assertThat(jinjava.render("{{ users|join(', ', attribute='username') }}", new HashMap())) - .isEqualTo("foo, bar"); + assertThat( + jinjava.render("{{ users|join(', ', attribute='username') }}", new HashMap<>()) + ) + .isEqualTo("foo, bar"); + } + + @Test + public void itTruncatesStringToConfigLimit() { + jinjava = + new Jinjava(BaseJinjavaTest.newConfigBuilder().withMaxStringLength(5).build()); + + RenderResult result = jinjava.renderForResult( + "{{ [1, 2, 3, 4, 5]|join('|') }}", + new HashMap<>() + ); + assertThat(result.getOutput()).isEqualTo("1|2|3"); + assertThat(result.getErrors().size()).isEqualTo(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("filter has been truncated to the max String length"); } public static class User { + private String username; public User(String username) { diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/LastFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/LastFilterTest.java index 09303d122..57600765c 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/LastFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/LastFilterTest.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.Arrays; - import org.junit.Before; import org.junit.Test; @@ -31,5 +30,4 @@ public void lastForSingleItemList() { public void lastForSeq() { assertThat(filter.filter(Arrays.asList(1, 2, 3), null)).isEqualTo(3); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ListFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ListFilterTest.java new file mode 100644 index 000000000..c99ccca2d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ListFilterTest.java @@ -0,0 +1,174 @@ +/********************************************************************** +Copyright (c) 2016 HubSpot 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.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.junit.Before; +import org.junit.Test; + +public class ListFilterTest { + + ListFilter filter; + + @Before + public void setup() { + filter = new ListFilter(); + } + + @Test + public void itConvertsStringToListOfChars() { + List o = (List) filter.filter("hello", null); + assertThat(o).isEqualTo(Lists.newArrayList('h', 'e', 'l', 'l', 'o')); + assertThat(o.get(0)).isEqualTo('h'); + } + + @Test + public void itConvertsSetsToLists() { + Set ints = Sets.newHashSet(1, 2, 3); + ints = new TreeSet(ints); // Converting to TreeSet to avoid non-deterministic permutations. + List o = (List) filter.filter(ints, null); + assertThat(o).isEqualTo(Lists.newArrayList(1, 2, 3)); + } + + @Test + public void itWrapsNonCollectionNonStringsInLists() { + List o = (List) filter.filter(BigDecimal.ONE, null); + assertThat(o).isEqualTo(Lists.newArrayList(BigDecimal.ONE)); + } + + @Test + public void itHandlesNullListParams() { + List o = (List) filter.filter(null, null); + assertThat(o).isNull(); + } + + @Test + public void itHandlesBoolean() { + boolean[] array = { true, false, true }; + + Object result = filter.filter(array, null); + + doAssertions(result, Boolean.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesByte() { + byte[] array = { 1, 2, 3 }; + + Object result = filter.filter(array, null); + + doAssertions(result, Byte.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesChar() { + char[] array = { 'a', 'b', 'c' }; + + Object result = filter.filter(array, null); + + doAssertions(result, Character.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesShort() { + short[] array = { 1, 2, 3 }; + + Object result = filter.filter(array, null); + + doAssertions(result, Short.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesInt() { + int[] array = { 1, 2, 3 }; + + Object result = filter.filter(array, null); + + doAssertions(result, Integer.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesLong() { + long[] array = { 1L, 2L, 3L }; + + Object result = filter.filter(array, null); + + doAssertions(result, Long.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesFloat() { + float[] array = { 1, 2, 3 }; + + Object result = filter.filter(array, null); + + doAssertions(result, Float.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesDouble() { + double[] array = { 1.0, 2.0, 3.0 }; + + Object result = filter.filter(array, null); + + doAssertions(result, Double.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesString() { + String[] array = { "word", "word2", "word3" }; + + Object result = filter.filter(array, null); + + doAssertions(result, String.class, array[0], array[1], array[2]); + } + + @Test + public void itHandlesInputNull() { + Object result = filter.filter(null, null); + + assertNull(result); + } + + @Test + public void itHandlesGetName() { + String name = filter.getName(); + + assertEquals("list", name); + } + + private void doAssertions(Object result, Class classOfElements, Object... elements) { + assertNotNull(result); + assertTrue(result instanceof List); + List resultList = (List) result; + assertEquals(elements.length, resultList.size()); + for (int i = 0; i < elements.length; i++) { + assertEquals(elements[i], resultList.get(i)); + assertEquals(classOfElements, resultList.get(i).getClass()); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/LogFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/LogFilterTest.java new file mode 100644 index 000000000..54625dc43 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/LogFilterTest.java @@ -0,0 +1,66 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import java.math.BigDecimal; +import org.junit.Before; +import org.junit.Test; + +public class LogFilterTest extends BaseInterpretingTest { + + LogFilter filter; + + @Before + public void setup() { + filter = new LogFilter(); + } + + @Test + public void itCalculatesLogBase2() { + assertThat(filter.filter(65536, interpreter, "2")).isEqualTo(16D); + } + + @Test + public void itCalculatesLogBase10() { + assertThat(filter.filter(100, interpreter, "10")).isEqualTo(2D); + } + + @Test + public void itCalculatesLogBaseN() { + assertThat(filter.filter(9765625d, interpreter, "45.123")) + .isEqualTo(4.224920597763891); + } + + @Test + public void itCalculatesBigDecimalLogBaseN() { + BigDecimal result = (BigDecimal) filter.filter( + new BigDecimal(9765625d), + interpreter, + "45.123" + ); + assertThat(result.doubleValue()).isEqualTo(4.224920597763891); + } + + @Test(expected = InvalidInputException.class) + public void itErrorsOnNegativeInput() { + filter.filter(-10d, interpreter, "5.0"); + } + + @Test(expected = InvalidInputException.class) + public void itErrorsOnStringInput() { + filter.filter("not a number", interpreter, "5.0"); + } + + @Test(expected = InvalidArgumentException.class) + public void itErrorsOnNegativeArgument() { + filter.filter(10d, interpreter, "-5.0"); + } + + @Test(expected = InvalidArgumentException.class) + public void itErrorsOnStringArgument() { + filter.filter(10d, interpreter, "not a number"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/MapFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/MapFilterTest.java index 5f7cd76f9..f4456aa1c 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/MapFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/MapFilterTest.java @@ -1,36 +1,120 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.Before; -import org.junit.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.lib.filter.JoinFilterTest.User; +import org.junit.Test; -public class MapFilterTest { +public class MapFilterTest extends BaseJinjavaTest { - Jinjava jinjava; + @Test + public void mapAttr() { + assertThat( + jinjava.render( + "{{ users|map(attribute='username')|join(', ') }}", + ImmutableMap.of( + "users", + (Object) Lists.newArrayList(new User("foo"), new User("bar")) + ) + ) + ) + .isEqualTo("foo, bar"); + } - @Before - public void setup() { - jinjava = new Jinjava(); + @Test + public void mapFilter() { + assertThat( + jinjava.render( + "{{ titles|map('lower')|join(', ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList("Happy Day", "FOO", "bar")) + ) + ) + .isEqualTo("happy day, foo, bar"); } @Test - public void mapAttr() { - assertThat(jinjava.render("{{ users|map(attribute='username')|join(', ') }}", - ImmutableMap.of("users", (Object) Lists.newArrayList(new User("foo"), new User("bar"))))) - .isEqualTo("foo, bar"); + public void itPassesAdditionalArgumentsIntoFilter() { + assertThat( + jinjava.render( + "{{ titles|map('truncate', 5, true, '')|join(', ') }}", + ImmutableMap.of( + "titles", + (Object) Lists.newArrayList("Happy Day", "FOOBAR", "barfoo") + ) + ) + ) + .isEqualTo("Happy, FOOBA, barfo"); } @Test - public void mapFilter() { - assertThat(jinjava.render("{{ titles|map('lower')|join(', ') }}", - ImmutableMap.of("titles", (Object) Lists.newArrayList("Happy Day", "FOO", "bar")))) - .isEqualTo("happy day, foo, bar"); + public void itUsesAttributeIfAttributeNameClashesWithFilter() { + assertThat( + jinjava.render( + "{{ titles|map(attribute='date')|join(' ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList(new TestClass(12345))) + ) + ) + .isEqualTo("12345"); + } + + @Test + public void itMapsFirstArgumentToFilterIfFilterExists() { + assertThatThrownBy(() -> + jinjava.render( + "{{ titles|map('date')|join(' ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList(new TestClass(12345))) + ) + ) + .hasMessageContaining("Input to function must be a date object"); } + @Test + public void itAddsErrorIfFirstArgumentIsNull() { + assertThatThrownBy(() -> + jinjava.render( + "{{ titles|map(null)|join(' ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList(new TestClass(12345))) + ) + ) + .hasMessageContaining("1st argument cannot be null"); + } + + @Test + public void itAddsErrorIfAttributeArgumentIsNull() { + assertThatThrownBy(() -> + jinjava.render( + "{{ titles|map(attribute=null)|join(' ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList(new TestClass(12345))) + ) + ) + .hasMessageContaining("'attribute' argument cannot be null"); + } + + @Test + public void itAddsErrorIfNoArgumentsAreProvided() { + assertThatThrownBy(() -> + jinjava.render( + "{{ titles|map()|join(' ') }}", + ImmutableMap.of("titles", (Object) Lists.newArrayList(new TestClass(12345))) + ) + ) + .hasMessageContaining("requires 1 argument"); + } + + public class TestClass { + + private final long date; + + public TestClass(long date) { + this.date = date; + } + + public long getDate() { + return date; + } + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/MinusTimeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/MinusTimeFilterTest.java new file mode 100644 index 000000000..66b614409 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/MinusTimeFilterTest.java @@ -0,0 +1,68 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Map; +import org.junit.Test; + +public class MinusTimeFilterTest extends BaseJinjavaTest { + + @Test + public void itSubtractsTime() { + long timestamp = 1543352736000L; + + long oneDay = 1543266336000L; + long oneHour = 1543349136000L; + long oneMinute = 1543352676000L; + long oneSecond = 1543352735000L; + long oneMonth = 1540674336000L; + Map vars = ImmutableMap.of( + "test", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ); + assertThat(jinjava.render("{{ test|minus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + assertThat(jinjava.render("{{ test|minus_time(1, 'hours')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneHour)); + assertThat(jinjava.render("{{ test|minus_time(1, 'minutes')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneMinute)); + assertThat(jinjava.render("{{ test|minus_time(1, 'seconds')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneSecond)); + assertThat(jinjava.render("{{ test|minus_time(1, 'months')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneMonth)); + + vars = + ImmutableMap.of( + "test", + new PyishDate( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ) + ); + assertThat(jinjava.render("{{ test|minus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + + vars = ImmutableMap.of("test", timestamp); + assertThat(jinjava.render("{{ test|minus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + } + + @Test + public void itWarnsOnDateTimeException() { + long timestamp = 1543352736000L; + + Map vars = ImmutableMap.of("test", timestamp); + RenderResult renderResult = jinjava.renderForResult( + "{{ test|minus_time(9999999999, 'years')|unixtimestamp }}", + vars + ); + assertThat(renderResult.getOutput()).isEqualTo(String.valueOf(timestamp)); + assertThat(renderResult.getErrors()).hasSize(1); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/MultiplyFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/MultiplyFilterTest.java new file mode 100644 index 000000000..d9eef38c3 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/MultiplyFilterTest.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class MultiplyFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(MultiplyFilter.class); + } + + @Test + public void itMultipliesDecimalNumbers() { + Map vars = ImmutableMap.of("test", 10); + RenderResult renderResult = jinjava.renderForResult("{{ test|multiply(.25) }}", vars); + assertThat(renderResult.getOutput()).isEqualTo("2.50"); + } + + @Test + public void itCoercesStringsToNumbers() { + Map vars = ImmutableMap.of("test", "10"); + RenderResult renderResult = jinjava.renderForResult("{{ test|multiply(.25) }}", vars); + assertThat(renderResult.getOutput()).isEqualTo("2.50"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/PlusTimeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/PlusTimeFilterTest.java new file mode 100644 index 000000000..b5e92b801 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/PlusTimeFilterTest.java @@ -0,0 +1,108 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Map; +import org.junit.Test; + +public class PlusTimeFilterTest extends BaseJinjavaTest { + + @Test + public void itAddsTime() { + long timestamp = 1543352736000L; + + long oneDay = 1543439136000L; + long oneHour = 1543356336000L; + long oneMinute = 1543352796000L; + long oneSecond = 1543352737000L; + long oneMonth = 1545944736000L; + Map vars = ImmutableMap.of( + "test", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ); + assertThat(jinjava.render("{{ test|plus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + assertThat(jinjava.render("{{ test|plus_time(1, 'hours')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneHour)); + assertThat(jinjava.render("{{ test|plus_time(1, 'minutes')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneMinute)); + assertThat(jinjava.render("{{ test|plus_time(1, 'seconds')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneSecond)); + assertThat(jinjava.render("{{ test|plus_time(1, 'months')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneMonth)); + + vars = + ImmutableMap.of( + "test", + new PyishDate( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ) + ); + assertThat(jinjava.render("{{ test|plus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + + vars = ImmutableMap.of("test", timestamp); + assertThat(jinjava.render("{{ test|plus_time(1, 'days')|unixtimestamp }}", vars)) + .isEqualTo(String.valueOf(oneDay)); + } + + @Test + public void itErrorsOnInvalidVariableType() { + Map vars = ImmutableMap.of("test", "not a date"); + RenderResult renderResult = jinjava.renderForResult( + "{{ test|plus_time(1, 'days')|unixtimestamp }}", + vars + ); + assertThat(renderResult.getErrors()).hasSize(1); + } + + @Test + public void itErrorsOnInvalidDelta() { + long timestamp = 1543352736000L; + + Map vars = ImmutableMap.of( + "test", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ); + RenderResult renderResult = jinjava.renderForResult( + "{{ test|plus_time('not a number', 'days')|unixtimestamp }}", + vars + ); + assertThat(renderResult.getErrors()).hasSize(1); + } + + @Test + public void itErrorsOnInvalidTemporalUnit() { + long timestamp = 1543352736000L; + + Map vars = ImmutableMap.of( + "test", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + ); + RenderResult renderResult = jinjava.renderForResult( + "{{ test|plus_time(1, 'parsec')|unixtimestamp }}", + vars + ); + assertThat(renderResult.getErrors()).hasSize(1); + } + + @Test + public void itWarnsOnDateTimeException() { + long timestamp = 1543352736000L; + + Map vars = ImmutableMap.of("test", timestamp); + RenderResult renderResult = jinjava.renderForResult( + "{{ test|plus_time(9999999999, 'years')|unixtimestamp }}", + vars + ); + assertThat(renderResult.getOutput()).isEqualTo(String.valueOf(timestamp)); + assertThat(renderResult.getErrors()).hasSize(1); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilterTest.java index 1b69732e5..36b56eede 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/PrettyPrintFilterTest.java @@ -2,28 +2,37 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.date.PyishDate; import java.time.ZoneOffset; import java.time.ZonedDateTime; - import org.junit.Before; import org.junit.Test; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.objects.date.PyishDate; - public class PrettyPrintFilterTest { + JinjavaInterpreter i; PrettyPrintFilter f; @Before public void setup() { + JinjavaConfig config = BaseJinjavaTest.newConfigBuilder().build(); + Jinjava jinjava = new Jinjava(config); + Context context = jinjava.getGlobalContext(); + i = new JinjavaInterpreter(jinjava, context, config); f = new PrettyPrintFilter(); } @Test public void ppString() { - assertThat(f.filter("foobar", null)).isEqualTo("{% raw %}(String: foobar){% endraw %}"); + assertThat(f.filter("foobar", i)).isEqualTo("{% raw %}(String: foobar){% endraw %}"); } @Test @@ -33,26 +42,52 @@ public void ppInt() { @Test public void ppPyDate() { - assertThat(f.filter(new PyishDate(ZonedDateTime.of(2014, 8, 4, 0, 0, 0, 0, ZoneOffset.UTC)), null)).isEqualTo("{% raw %}(PyishDate: 2014-08-04 00:00:00){% endraw %}"); + assertThat( + f.filter( + new PyishDate(ZonedDateTime.of(2014, 8, 4, 0, 0, 0, 0, ZoneOffset.UTC)), + null + ) + ) + .isEqualTo("{% raw %}(PyishDate: 2014-08-04 00:00:00){% endraw %}"); } @Test public void ppMap() { - assertThat(f.filter(ImmutableMap.of("a", "foo", "b", "bar"), null)) - .isEqualTo("{% raw %}(RegularImmutableMap: {a=foo, b=bar}){% endraw %}"); + assertThat(f.filter(ImmutableMap.of("b", "foo", "a", "bar"), null)) + .isEqualTo("{% raw %}(RegularImmutableMap: {a=bar, b=foo}){% endraw %}"); } @Test public void ppList() { - assertThat(f.filter(Lists.newArrayList("foo", "bar"), null)).isEqualTo("{% raw %}(ArrayList: [foo, bar]){% endraw %}"); + assertThat(f.filter(Lists.newArrayList("foo", "bar"), null)) + .isEqualTo("{% raw %}(ArrayList: [foo, bar]){% endraw %}"); } @Test public void ppObject() { - assertThat(f.filter(new MyClass(), null)).isEqualTo("{% raw %}(MyClass: {bar=123, foo=foofoo}){% endraw %}"); + MyClass myClass = new MyClass(); + assertThat(f.filter(myClass, i)) + .isEqualTo( + String.format( + "{%% raw %%}(MyClass: {\n" + + " "foo" : "%s",\n" + + " "bar" : %d,\n" + + " "nested_class" : {\n" + + " "foo_field" : "%s",\n" + + " "bar_field" : %d\n" + + " }\n" + + "}){%% endraw %%}", + myClass.getFoo(), + myClass.getBar(), + myClass.getNestedClass().getFooField(), + myClass.getNestedClass().getBarField() + ) + ); } + @JsonPropertyOrder({ "foo", "bar", "nestedClass" }) public static class MyClass { + public String getFoo() { return "foofoo"; } @@ -60,6 +95,21 @@ public String getFoo() { public int getBar() { return 123; } + + public MyNestedClass getNestedClass() { + return new MyNestedClass(); + } } + @JsonPropertyOrder({ "fooField", "barField" }) + public static class MyNestedClass { + + public String getFooField() { + return "foofieldfoofield"; + } + + public int getBarField() { + return 123; + } + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RandomFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RandomFilterTest.java new file mode 100644 index 000000000..1a579de12 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RandomFilterTest.java @@ -0,0 +1,136 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.random.ConstantZeroRandomNumberGenerator; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.concurrent.ThreadLocalRandom; +import org.junit.Before; +import org.junit.Test; + +public class RandomFilterTest { + + RandomFilter filter = new RandomFilter(); + + JinjavaInterpreter interpreter = mock(JinjavaInterpreter.class); + JinjavaConfig config = mock(JinjavaConfig.class); + + @Before + public void setUp() throws Exception { + when(interpreter.getRandom()).thenReturn(ThreadLocalRandom.current()); + when(interpreter.getConfig()).thenReturn(config); + when(config.getRandomNumberGeneratorStrategy()) + .thenReturn(RandomNumberGeneratorStrategy.THREAD_LOCAL); + } + + @Test + public void itReturnsNullWithNullObject() { + assertThat(filter.filter(null, interpreter)).isNull(); + } + + @Test + public void itTakesRandomElementFromList() { + Collection ints = Arrays.asList(1, 2); + Object random = filter.filter(ints, interpreter); + assertThat((Integer) random).isBetween(1, 2); + } + + @Test + public void itReturnsNullFromEmptyList() { + assertThat(filter.filter(Collections.emptyList(), interpreter)).isNull(); + } + + @Test + public void itAlwaysUsesFirstListValueWhenUsingConstantRandom() { + setUpConstantRandom(); + + Collection ints = Arrays.asList(1, 2); + assertThat((Integer) filter.filter(ints, interpreter)).isEqualTo(1); + } + + @Test + public void itTakesRandomElementFromArray() { + assertThat((Integer) filter.filter(new Integer[] { 1, 2 }, interpreter)) + .isBetween(1, 2); + } + + @Test + public void itReturnsNullFromEmptyArray() { + assertThat(filter.filter(new Integer[] {}, interpreter)).isNull(); + } + + @Test + public void itAlwaysUsesFirstArrayValueWhenUsingConstantRandom() { + setUpConstantRandom(); + assertThat((Integer) filter.filter(new Integer[] { 1, 2 }, interpreter)).isEqualTo(1); + } + + private void setUpConstantRandom() { + when(interpreter.getRandom()).thenReturn(new ConstantZeroRandomNumberGenerator()); + when(interpreter.getConfig()).thenReturn(config); + when(config.getRandomNumberGeneratorStrategy()) + .thenReturn(RandomNumberGeneratorStrategy.CONSTANT_ZERO); + } + + @Test + public void itTakesRandomElementFromMap() { + LinkedHashMap map = new LinkedHashMap<>(); + map.put(1, 3); + map.put(2, 4); + assertThat((Integer) filter.filter(map, interpreter)).isBetween(3, 4); + } + + @Test + public void itReturnsNullFromEmptyMap() { + assertThat(filter.filter(Collections.emptyMap(), interpreter)).isNull(); + } + + @Test + public void itAlwaysUsesFirstMapValueWhenUsingConstantRandom() { + setUpConstantRandom(); + LinkedHashMap map = new LinkedHashMap<>(); + map.put(1, 3); + map.put(2, 4); + assertThat((Integer) filter.filter(map, interpreter)).isEqualTo(3); + } + + @Test + public void itGeneratesRandomNumberInRange() { + assertThat((Integer) filter.filter(2, interpreter)).isBetween(0, 2); + } + + @Test + public void itAlwaysReturnsRangeWhenUsingConstantRandom() { + setUpConstantRandom(); + assertThat((Integer) filter.filter(3, interpreter)).isEqualTo(0); + } + + @Test + public void itGeneratesRandomNumberWithStringRange() { + assertThat((Integer) filter.filter("2.0", interpreter)).isBetween(0, 2); + } + + @Test + public void itReturnsZeroWithBadStringRange() { + assertThat((Integer) filter.filter("what", interpreter)).isEqualTo(0); + } + + @Test + public void itAlwaysReturnsZeroWhenUsingConstantRandom() { + setUpConstantRandom(); + assertThat((Integer) filter.filter("whut", interpreter)).isEqualTo(0); + } + + @Test + public void itReturnsOriginalObjectForOtherClasses() { + assertThat((JinjavaInterpreter) filter.filter(interpreter, interpreter)) + .isEqualTo(interpreter); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilterTest.java new file mode 100644 index 000000000..202aa4883 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RegexReplaceFilterTest.java @@ -0,0 +1,83 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Before; +import org.junit.Test; + +public class RegexReplaceFilterTest extends BaseInterpretingTest { + + RegexReplaceFilter filter; + + @Before + public void setup() { + filter = new RegexReplaceFilter(); + } + + @Test + public void expects2Args() { + assertThatThrownBy(() -> filter.filter("foo", interpreter)) + .hasMessageContaining("requires 2 arguments"); + } + + @Test + public void expectsNotNullArgs() { + assertThatThrownBy(() -> + filter.filter("foo", interpreter, new String[] { null, null }) + ) + .hasMessageContaining("both a valid regex"); + } + + public void noopOnNullExpr() { + assertThat(filter.filter(null, interpreter, "foo", "bar")).isNull(); + } + + @Test + public void itMatchesRegexAndReplacesString() { + assertThat(filter.filter("It costs $300", interpreter, "[^a-zA-Z]", "")) + .isEqualTo("Itcosts"); + } + + @Test(expected = InvalidArgumentException.class) + public void isThrowsExceptionOnInvalidRegex() { + filter.filter("It costs $300", interpreter, "[", ""); + } + + @Test + public void itMatchesRegexAndReplacesStringForSafeString() { + assertThat( + filter + .filter(new SafeString("It costs $300"), interpreter, "[^a-zA-Z]", "") + .toString() + ) + .isEqualTo("Itcosts"); + } + + @Test + public void itLimitsLongInput() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 101; i++) { + sb.append('a'); + } + assertThatThrownBy(() -> + filter.filter( + sb.toString(), + new Jinjava(BaseJinjavaTest.newConfigBuilder().withMaxStringLength(10).build()) + .newInterpreter(), + "O", + "0" + ) + ) + .isInstanceOf(InvalidInputException.class) + .hasMessageContaining( + "Invalid input for 'regex_replace': input with length '101' exceeds maximum allowed length of '10'" + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RejectAttrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RejectAttrFilterTest.java new file mode 100644 index 000000000..9d7ed44e7 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RejectAttrFilterTest.java @@ -0,0 +1,144 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class RejectAttrFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava + .getGlobalContext() + .put( + "users", + Lists.newArrayList( + new User(0, false, "foo@bar.com", new Option(0, "option0")), + new User(1, true, "bar@bar.com", new Option(1, "option1")), + new User(2, false, null, new Option(2, "option2")) + ) + ); + } + + @Test + public void rejectAttrWithNoExp() { + assertThat( + jinjava.render("{{ users|rejectattr('is_active') }}", new HashMap()) + ) + .isEqualTo("[0, 2]"); + } + + @Test + public void rejectAttrWithExp() { + assertThat( + jinjava.render( + "{{ users|rejectattr('email', 'none') }}", + new HashMap() + ) + ) + .isEqualTo("[0, 1]"); + } + + @Test + public void rejectAttrWithIsEqualToExp() { + assertThat( + jinjava.render( + "{{ users|rejectattr('email', 'equalto', 'bar@bar.com') }}", + new HashMap() + ) + ) + .isEqualTo("[0, 2]"); + } + + @Test + public void rejectAttrWithNestedProperty() { + assertThat( + jinjava.render( + "{{ users|rejectattr('option.id', 'equalto', 1) }}", + new HashMap() + ) + ) + .isEqualTo("[0, 2]"); + + assertThat( + jinjava.render( + "{{ users|rejectattr('option.name', 'equalto', 'option0') }}", + new HashMap() + ) + ) + .isEqualTo("[1, 2]"); + } + + public static class User implements PyishSerializable { + + private int num; + private boolean isActive; + private String email; + private Option option; + + public User(int num, boolean isActive, String email, Option option) { + this.num = num; + this.isActive = isActive; + this.email = email; + this.option = option; + } + + public int getNum() { + return num; + } + + public String getEmail() { + return email; + } + + public boolean getIsActive() { + return isActive; + } + + public Option getOption() { + return option; + } + + @Override + public String toString() { + return num + ""; + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } + } + + public static class Option { + + private long id; + private String name; + + public Option(long id, String name) { + this.id = id; + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return id + ""; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RejectFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RejectFilterTest.java new file mode 100644 index 000000000..8397efbef --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RejectFilterTest.java @@ -0,0 +1,41 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class RejectFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().put("numbers", Lists.newArrayList(1L, 2L, 3L, 4L, 5L)); + } + + @Test + public void testReject() { + assertThat(jinjava.render("{{numbers|reject('odd')}}", new HashMap<>())) + .isEqualTo("[2, 4]"); + assertThat(jinjava.render("{{numbers|reject('even')}}", new HashMap<>())) + .isEqualTo("[1, 3, 5]"); + } + + @Test + public void testRejectWithEqualToAttr() { + assertThat(jinjava.render("{{numbers|reject('equalto', 3)}}", new HashMap<>())) + .isEqualTo("[1, 2, 4, 5]"); + } + + @Test + public void itThrowsInvalidArgumentForNullExpTestArgument() { + Map context = new HashMap<>(); + context.put("test", null); + assertThatThrownBy(() -> jinjava.render("{{numbers|reject(test, 3)}}", context)) + .hasMessageContaining("'exp_test' argument cannot be null"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RenderFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RenderFilterTest.java new file mode 100644 index 000000000..13b0bbe82 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RenderFilterTest.java @@ -0,0 +1,86 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import org.junit.Before; +import org.junit.Test; + +public class RenderFilterTest extends BaseInterpretingTest { + + private RenderFilter filter; + + @Before + public void setup() { + filter = new RenderFilter(); + } + + @Test + public void itRendersObject() { + String stringToRender = "{% if null %}Hello{% else %}world{% endif %}"; + + assertThat(filter.filter(stringToRender, interpreter)).isEqualTo("world"); + } + + @Test + public void itRendersObjectWithinLimit() { + String stringToRender = "{% if null %}Hello{% else %}world{% endif %}"; + + assertThat(filter.filter(stringToRender, interpreter, "5")).isEqualTo("world"); + } + + @Test + public void itDoesNotRenderObjectOverLimit() { + String stringToRender = "{% if null %}Hello{% else %}world{% endif %}"; + + assertThat(filter.filter(stringToRender, interpreter, "4")).isEqualTo(""); + } + + @Test + public void itRendersPartialObjectOverLimit() { + String stringToRender = "Hello{% if null %}Hello{% else %}world{% endif %}"; + + assertThat(filter.filter(stringToRender, interpreter, "7")).isEqualTo("Hello"); + } + + @Test + public void itCountsHtmlTags() { + String stringToRender = "

Hello

{% if null %}Hello{% else %}world{% endif %}"; + + assertThat(filter.filter(stringToRender, interpreter, "15")) + .isEqualTo("

Hello

"); + } + + @Test + public void itDoesNotAlwaysCompleteHtmlTags() { + String stringToRender = + "

Hello, {% if null %}world{% else %}world!{% endif %}

"; + + assertThat(filter.filter(stringToRender, interpreter, "17")) + .isEqualTo("

Hello, world!"); + } + + @Test + public void itDoesNotProcessExtendsRoots() { + String stringToRender = + "{% extends 'filter/render/base.jinja' -%}\n" + + "{% block body %}\n" + + "I am the extension body\n" + + "{% endblock %}" + + "You should never see this text in the output!\n" + + "{%- set foo = '{{ 1 + 1}}'|render -%}\n" + + "{% block footer %}\n" + + "I am the extension footer\n" + + "{% endblock %}"; + + assertThat(interpreter.render(stringToRender)) + .isEqualTo( + "Body is: \n" + + "I am the extension body\n" + + "\n" + + "Footer is: \n" + + "I am the extension footer\n" + + "\n" + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ReplaceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ReplaceFilterTest.java index 1a093c44f..3c0e93de4 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/ReplaceFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ReplaceFilterTest.java @@ -1,22 +1,23 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import org.junit.Before; -import org.junit.Test; - +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.interpret.InterpretException; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Before; +import org.junit.Test; -public class ReplaceFilterTest { +public class ReplaceFilterTest extends BaseInterpretingTest { - JinjavaInterpreter interpreter; ReplaceFilter filter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); filter = new ReplaceFilter(); } @@ -25,19 +26,56 @@ public void expectsAtLeast2Args() { filter.filter("foo", interpreter); } - @Test(expected = InterpretException.class) - public void expectsFilterVar() { - filter.filter(null, interpreter, "foo", "bar"); + public void noopOnNullExpr() { + assertThat(filter.filter(null, interpreter, "foo", "bar")).isNull(); } @Test public void replaceString() { - assertThat(filter.filter("hello world", interpreter, "hello", "goodbye")).isEqualTo("goodbye world"); + assertThat(filter.filter("hello world", interpreter, "hello", "goodbye")) + .isEqualTo("goodbye world"); } @Test public void replaceWithCount() { - assertThat(filter.filter("aaaaargh", interpreter, "a", "d'oh, ", "2")).isEqualTo("d'oh, d'oh, aaargh"); + assertThat(filter.filter("aaaaargh", interpreter, "a", "d'oh, ", "2")) + .isEqualTo("d'oh, d'oh, aaargh"); + } + + @Test + public void replaceSafeStringWithCount() { + assertThat( + filter + .filter(new SafeString("aaaaargh"), interpreter, "a", "d'oh, ", "2") + .toString() + ) + .isEqualTo("d'oh, d'oh, aaargh"); } + @Test + public void replaceBoolean() { + assertThat(filter.filter(true, interpreter, "true", "TRUEEE").toString()) + .isEqualTo("TRUEEE"); + } + + @Test + public void itLimitsLongInput() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 101; i++) { + sb.append('a'); + } + assertThatThrownBy(() -> + filter.filter( + sb.toString(), + new Jinjava(BaseJinjavaTest.newConfigBuilder().withMaxStringLength(10).build()) + .newInterpreter(), + "O", + "0" + ) + ) + .isInstanceOf(InvalidInputException.class) + .hasMessageContaining( + "Invalid input for 'replace': input with length '101' exceeds maximum allowed length of '10'" + ); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ReverseFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ReverseFilterTest.java new file mode 100644 index 000000000..5d60a5d90 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ReverseFilterTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class ReverseFilterTest extends BaseJinjavaTest { + + @Test + public void itReversesPrimitiveIntArray() { + Map context = new HashMap<>(); + context.put("arr", new int[] { 1, 2, 3 }); + assertThat( + jinjava.render("{% for item in arr|reverse %}{{ item }}{% endfor %}", context) + ) + .isEqualTo("321"); + } + + @Test + public void itReversesObjectArray() { + Map context = new HashMap<>(); + context.put("arr", new String[] { "a", "b", "c" }); + assertThat( + jinjava.render("{% for item in arr|reverse %}{{ item }}{% endfor %}", context) + ) + .isEqualTo("cba"); + } + + @Test + public void itAllowsIndexingWhenLegacyOverrideIsDisabled() { + Jinjava legacyJinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withIteratorOnlyReverseFilter(false) + .build() + ) + .build() + ); + Map context = new HashMap<>(); + context.put("arr", new String[] { "a", "b", "c" }); + assertThat(legacyJinjava.render("{{ (arr|reverse)[0] }}", context)).isEqualTo("c"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RootFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RootFilterTest.java new file mode 100644 index 000000000..ee2c04a72 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RootFilterTest.java @@ -0,0 +1,63 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import java.math.BigDecimal; +import org.junit.Before; +import org.junit.Test; + +public class RootFilterTest extends BaseInterpretingTest { + + RootFilter filter; + + @Before + public void setup() { + filter = new RootFilter(); + } + + @Test + public void itCalculatesSquareRoot() { + assertThat(filter.filter(100, interpreter)).isEqualTo(10D); + assertThat(filter.filter(22500, interpreter)).isEqualTo(150D); + } + + @Test + public void itCalculatesCubeRoot() { + assertThat(filter.filter(125, interpreter, "3")).isEqualTo(5d); + } + + @Test + public void itCalculatesNthRoot() { + assertThat(filter.filter(9765625d, interpreter, "5.0")).isEqualTo(25d); + } + + @Test + public void itCalculatesNthRootOfBigDecimal() { + BigDecimal result = + ((BigDecimal) filter.filter(new BigDecimal(9765625d), interpreter, "5.0")); + assertThat(result.doubleValue()).isEqualTo(25d); + } + + @Test(expected = InvalidInputException.class) + public void itErrorsOnNegativeInput() { + filter.filter(-10d, interpreter, "5.0"); + } + + @Test(expected = InvalidInputException.class) + public void itErrorsOnStringInput() { + filter.filter("not a number", interpreter, "5.0"); + } + + @Test(expected = InvalidArgumentException.class) + public void itErrorsOnNegativeArgument() { + filter.filter(10d, interpreter, "-5.0"); + } + + @Test(expected = InvalidArgumentException.class) + public void itErrorsOnStringArgument() { + filter.filter(10d, interpreter, "not a number"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/RoundFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RoundFilterTest.java index 7b24214fb..99c9317cc 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/RoundFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RoundFilterTest.java @@ -2,29 +2,26 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; import java.util.HashMap; - -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; - -public class RoundFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class RoundFilterTest extends BaseJinjavaTest { @Test public void roundDefault() { - assertThat(jinjava.render("{{ 42.55|round }}", new HashMap())).isEqualTo("43"); + assertThat(jinjava.render("{{ 42.55|round }}", new HashMap<>())).isEqualTo("43"); } @Test public void roundFloor() { - assertThat(jinjava.render("{{ 42.55|round(1, 'floor') }}", new HashMap())).isEqualTo("42.5"); + assertThat(jinjava.render("{{ 42.55|round(1, 'floor') }}", new HashMap<>())) + .isEqualTo("42.5"); + } + + @Test + public void roundWithNullMethod() { + assertThat(jinjava.render("{{ 42.55|round(1, null) }}", new HashMap<>())) + .isEqualTo("42.6"); } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SafeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SafeFilterTest.java new file mode 100644 index 000000000..4ddda5775 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SafeFilterTest.java @@ -0,0 +1,124 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.hubspot.jinjava.BaseInterpretingTest; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SafeFilterTest extends BaseInterpretingTest { + + private static final String HTML = "Link"; + private static final List TEST_NUMBERS = ImmutableList.of(43, 1, 24); + private static final List TEST_STRINGS = ImmutableList.of( + "-100", + "1000", + "short", + "WeIrD", + "The quick Brown fox Jumped Over The Lazy Dog.", + " some whitespace here " + ); + private static final List STRING_FILTERS = ImmutableList.of( + "ipaddr", + "length", + "lower", + "upper", + "md5", + "reverse", + "trim", + "capitalize", + "cut('a')", + "center" + ); + private static final List NUMBER_FILTERS = ImmutableList.of( + "divide('5')", + "ipaddr", + "length", + "log", + "lower", + "upper", + "md5", + "multiply('9')", + "reverse", + "root" + ); + + @Before + public void setup() { + interpreter.getContext().setAutoEscape(true); + } + + @After + public void tearDown() throws Exception { + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itDoesNotEscapeStringMarkedAsSafe() throws Exception { + interpreter.getContext().put("html", HTML); + assertThat(interpreter.renderFlat("{{ html|safe }}")).isEqualTo(HTML); + } + + @Test + public void itPassesVarThroughIfNotInstanceOfString() throws Exception { + interpreter.getContext().put("number", -3); + assertThat(interpreter.renderFlat("{{ number|safe|abs }}")).isEqualTo("3"); + } + + @Test + public void itWorksWhenChainingFilters() throws Exception { + interpreter.getContext().put("safe_html", HTML); + assertThat(interpreter.renderFlat("{{ safe_html|safe|upper }}")) + .isEqualTo(HTML.toUpperCase()); + assertThat(interpreter.renderFlat("{{ safe_html|upper|safe }}")) + .isEqualTo(HTML.toUpperCase()); + assertThat(interpreter.renderFlat("{{ safe_html|safe|length }}")) + .isEqualTo(String.valueOf(HTML.length())); + assertThat(interpreter.renderFlat("{{ safe_html|safe|length|safe }}")) + .isEqualTo(String.valueOf(HTML.length())); + assertThat(interpreter.renderFlat("{{ safe_html|length }}")) + .isEqualTo(String.valueOf(HTML.length())); + } + + @Test + public void itWorksForAllRelevantFilters() throws Exception { + for (String testFilter : STRING_FILTERS) { + for (String testString : TEST_STRINGS) { + interpreter.getContext().put("string_under_test", testString); + assertThat( + interpreter.renderFlat("{{ string_under_test|safe|" + testFilter + "|safe }}") + ) + .as( + "Testing behaviour of filter with and without safe filter: " + + testFilter + + " on string " + + testString + ) + .isEqualTo( + interpreter.renderFlat("{{ string_under_test|" + testFilter + " }}") + ); + } + } + for (String testFilter : NUMBER_FILTERS) { + for (Integer testInt : TEST_NUMBERS) { + interpreter.getContext().put("string_under_test", testInt); + assertThat( + interpreter.renderFlat("{{ string_under_test|safe|" + testFilter + " }}") + ) + .as( + "Testing behaviour of filter with and without safe filter: " + + testFilter + + " on string " + + testInt + ) + .isEqualTo( + interpreter.renderFlat("{{ string_under_test|" + testFilter + " }}") + ); + } + } + assertThat(interpreter.renderFlat("{{ 1|safe|random }}")).isEqualTo("0"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SelectAttrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SelectAttrFilterTest.java index 99dc766c5..b3b845f3e 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SelectAttrFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SelectAttrFilterTest.java @@ -2,49 +2,173 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; import java.util.HashMap; - import org.junit.Before; import org.junit.Test; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; - -public class SelectAttrFilterTest { - - Jinjava jinjava; +public class SelectAttrFilterTest extends BaseJinjavaTest { @Before public void setup() { - jinjava = new Jinjava(); - jinjava.getGlobalContext().put("users", Lists.newArrayList( - new User(0, false, "foo@bar.com"), new User(1, true, "bar@bar.com"), new User(2, false, null))); + jinjava + .getGlobalContext() + .put( + "users", + Lists.newArrayList( + new User(0, false, "foo@bar.com", new Option(0, "option0")), + new User(1, true, "bar@bar.com", new Option(1, "option1")), + new User(2, false, null, new Option(2, "option2")) + ) + ); + jinjava + .getGlobalContext() + .put( + "numbers", + Lists.newArrayList( + ImmutableMap.of("number", 1), + ImmutableMap.of("number", 2), + ImmutableMap.of("number", 3), + ImmutableMap.of("number", 4) + ) + ); } @Test public void selectAttrWithNoExp() { - assertThat(jinjava.render("{{ users|selectattr('is_active') }}", new HashMap())) - .isEqualTo("[1]"); + assertThat( + jinjava.render("{{ users|selectattr('is_active') }}", new HashMap()) + ) + .isEqualTo("[1]"); } @Test public void selectAttrWithExp() { - assertThat(jinjava.render("{{ users|selectattr('email', 'none') }}", new HashMap())) - .isEqualTo("[2]"); + assertThat( + jinjava.render( + "{{ users|selectattr('email', 'none') }}", + new HashMap() + ) + ) + .isEqualTo("[2]"); + } + + @Test + public void selectAttrWithSymbolicExp() { + assertThat( + jinjava.render( + "{{ users|selectattr('isActive', '==', 'true') }}", + new HashMap() + ) + ) + .isEqualTo("[1]"); + } + + @Test + public void selectAttrWithIsEqualToExp() { + assertThat( + jinjava.render( + "{{ users|selectattr('email', 'equalto', 'bar@bar.com') }}", + new HashMap() + ) + ) + .isEqualTo("[1]"); + } + + @Test + public void selectAttrWithNumericIsEqualToExp() { + assertThat( + jinjava.render( + "{{ users|selectattr('num', 'equalto', 1) }}", + new HashMap() + ) + ) + .isEqualTo("[1]"); + } + + @Test + public void selectAttrWithNestedProperty() { + assertThat( + jinjava.render( + "{{ users|selectattr('option.id', 'equalto', 1) }}", + new HashMap() + ) + ) + .isEqualTo("[1]"); + + assertThat( + jinjava.render( + "{{ users|selectattr('option.name', 'equalto', 'option2') }}", + new HashMap() + ) + ) + .isEqualTo("[2]"); + } + + @Test + public void selectAttrWithSymbolicLtExp() { + assertThat( + jinjava.render( + "{{ numbers|selectattr('number', '<', '3')|map('number') }}", + new HashMap() + ) + ) + .isEqualTo("[1, 2]"); + } + + @Test + public void selectAttrWithSymbolicLeExp() { + assertThat( + jinjava.render( + "{{ numbers|selectattr('number', '<=', '3')|map('number') }}", + new HashMap() + ) + ) + .isEqualTo("[1, 2, 3]"); + } + + @Test + public void selectAttrWithSymbolicGtExp() { + assertThat( + jinjava.render( + "{{ numbers|selectattr('number', '>', '3')|map('number') }}", + new HashMap() + ) + ) + .isEqualTo("[4]"); + } + + @Test + public void selectAttrWithSymbolicGeExp() { + assertThat( + jinjava.render( + "{{ numbers|selectattr('number', '>=', '3')|map('number') }}", + new HashMap() + ) + ) + .isEqualTo("[3, 4]"); } - public static class User { - private int num; + public static class User implements PyishSerializable { + + private long num; private boolean isActive; private String email; + private Option option; - public User(int num, boolean isActive, String email) { + public User(long num, boolean isActive, String email, Option option) { this.num = num; this.isActive = isActive; this.email = email; + this.option = option; } - public int getNum() { + public long getNum() { return num; } @@ -56,10 +180,52 @@ public boolean getIsActive() { return isActive; } + public Option getOption() { + return option; + } + @Override public String toString() { return num + ""; } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } } + public static class Option implements PyishSerializable { + + private long id; + private String name; + + public Option(long id, String name) { + this.id = id; + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + @JsonValue + @Override + public String toString() { + return id + ""; + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SelectFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SelectFilterTest.java index e5f4d3e4c..cf0c65789 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SelectFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SelectFilterTest.java @@ -1,29 +1,41 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; import java.util.HashMap; - +import java.util.Map; import org.junit.Before; import org.junit.Test; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; - -public class SelectFilterTest { - - Jinjava jinjava; +public class SelectFilterTest extends BaseJinjavaTest { @Before public void setup() { - jinjava = new Jinjava(); - jinjava.getGlobalContext().put("numbers", Lists.newArrayList(1, 2, 3, 4, 5)); + jinjava.getGlobalContext().put("numbers", Lists.newArrayList(1L, 2L, 3L, 4L, 5L)); } @Test public void testSelect() { - assertThat(jinjava.render("{{numbers|select('odd')}}", new HashMap())).isEqualTo("[1, 3, 5]"); - assertThat(jinjava.render("{{numbers|select('even')}}", new HashMap())).isEqualTo("[2, 4]"); + assertThat(jinjava.render("{{numbers|select('odd')}}", new HashMap<>())) + .isEqualTo("[1, 3, 5]"); + assertThat(jinjava.render("{{numbers|select('even')}}", new HashMap<>())) + .isEqualTo("[2, 4]"); } + @Test + public void testSelectWithEqualToAttr() { + assertThat(jinjava.render("{{numbers|select('equalto', 3)}}", new HashMap<>())) + .isEqualTo("[3]"); + } + + @Test + public void itThrowsInvalidArgumentForNullExpTestArgument() { + Map context = new HashMap<>(); + context.put("test", null); + assertThatThrownBy(() -> jinjava.render("{{numbers|select(test, 3)}}", context)) + .hasMessageContaining("'exp_test' argument cannot be null"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ShuffleFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ShuffleFilterTest.java index 48b4f4bb4..bd1646d49 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/ShuffleFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ShuffleFilterTest.java @@ -2,27 +2,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown; +import static org.mockito.Mockito.*; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.random.ConstantZeroRandomNumberGenerator; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; import java.util.Arrays; import java.util.List; - -import org.junit.Before; +import java.util.concurrent.ThreadLocalRandom; import org.junit.Test; public class ShuffleFilterTest { - ShuffleFilter filter; + ShuffleFilter filter = new ShuffleFilter(); - @Before - public void setup() { - this.filter = new ShuffleFilter(); - } + JinjavaInterpreter interpreter = mock(JinjavaInterpreter.class); + JinjavaConfig config = mock(JinjavaConfig.class); @SuppressWarnings("unchecked") @Test - public void shuffleItems() { + public void itShufflesItems() { + when(interpreter.getRandom()).thenReturn(ThreadLocalRandom.current()); + when(interpreter.getConfig()).thenReturn(config); + when(config.getRandomNumberGeneratorStrategy()) + .thenReturn(RandomNumberGeneratorStrategy.THREAD_LOCAL); + List before = Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9"); - List after = (List) filter.filter(before, null); + List after = (List) filter.filter(before, interpreter); assertThat(before).isSorted(); assertThat(after).containsAll(before); @@ -35,4 +42,15 @@ public void shuffleItems() { } } + @Test + public void itShufflesConsistentlyWithConstantRandom() { + when(interpreter.getRandom()).thenReturn(new ConstantZeroRandomNumberGenerator()); + when(interpreter.getConfig()).thenReturn(config); + when(config.getRandomNumberGeneratorStrategy()) + .thenReturn(RandomNumberGeneratorStrategy.CONSTANT_ZERO); + + List before = Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9"); + + assertThat(before).isEqualTo(filter.filter(before, interpreter)); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java index b02d04b80..62e0ea820 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java @@ -1,39 +1,160 @@ package com.hubspot.jinjava.lib.filter; +import static com.hubspot.jinjava.lib.filter.SliceFilter.MAX_SLICES; import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.RenderResult; import java.nio.charset.StandardCharsets; - +import java.util.ArrayList; +import java.util.List; +import java.util.Random; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.junit.Before; import org.junit.Test; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; - -public class SliceFilterTest { - - Jinjava jinjava; +public class SliceFilterTest extends BaseJinjavaTest { @Before public void setup() { - jinjava = new Jinjava(); + jinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withKeepTrailingNewline(true).build() + ); } @Test - public void testSimpleSlice() throws Exception { + public void itSlicesLists() throws Exception { Document dom = Jsoup.parseBodyFragment( - jinjava.render( - Resources.toString(Resources.getResource("filter/slice-filter.jinja"), StandardCharsets.UTF_8), - ImmutableMap.of("items", (Object) Lists.newArrayList("a", "b", "c", "d", "e", "f", "g")))); + jinjava.render( + Resources.toString( + Resources.getResource("filter/slice-filter.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of( + "items", + (Object) Lists.newArrayList("a", "b", "c", "d", "e", "f", "g") + ) + ) + ); assertThat(dom.select(".columwrapper ul")).hasSize(3); assertThat(dom.select(".columwrapper .column-1 li")).hasSize(3); assertThat(dom.select(".columwrapper .column-2 li")).hasSize(3); - assertThat(dom.select(".columwrapper .column-3 li")).hasSize(3); + assertThat(dom.select(".columwrapper .column-3 li")).hasSize(1); + } + + @Test + public void itSlicesToTheMaxLimit() throws Exception { + String result = jinjava.render( + Resources.toString( + Resources.getResource("filter/slice-filter-big.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", Lists.newArrayList("a", "b", "c", "d", "e")) + ); + + assertThat(result).isNotEmpty(); + assertThat(result.split("\n")).hasSize(MAX_SLICES + 2); // 1 for each slice, 1 for the newline + } + + @Test + public void itSlicesListWithReplacement() throws Exception { + String result = jinjava.render( + Resources.toString( + Resources.getResource("filter/slice-filter-replacement.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", (Object) Lists.newArrayList("a", "b", "c", "d", "e")) + ); + + assertThat(result) + .isEqualTo( + "\n" + + " 1\n" + + " a\n" + + " b\n" + + " 2\n" + + " c\n" + + " d\n" + + " 3\n" + + " e\n" + + " hello\n" + + "" + ); } + @Test + public void itSlicesListWithReplacementButDivisibleSlices() throws Exception { + String result = jinjava.render( + Resources.toString( + Resources.getResource("filter/slice-filter-replacement.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", (Object) Lists.newArrayList("a", "b", "c", "d", "e", "f")) + ); + + assertThat(result) + .isEqualTo( + "\n" + + " 1\n" + + " a\n" + + " b\n" + + " 2\n" + + " c\n" + + " d\n" + + " 3\n" + + " e\n" + + " f\n" + + "" + ); + } + + @Test + public void itSlicesEmptyList() throws Exception { + String result = jinjava.render( + Resources.toString( + Resources.getResource("filter/slice-filter-empty.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", (Object) Lists.newArrayList()) + ); + + assertThat(result).isEqualTo("\n"); + } + + @Test + public void itAddsErrorOnNegativeSlice() throws Exception { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("filter/slice-filter-negative.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", (Object) Lists.newArrayList()) + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("with value -1 must be a positive number"); + } + + @Test + public void itAddsErrorOnZeroSlice() throws Exception { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("filter/slice-filter-zero.jinja"), + StandardCharsets.UTF_8 + ), + ImmutableMap.of("items", (Object) Lists.newArrayList()) + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("with value 0 must be a positive number"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SortFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SortFilterTest.java index 01bebbec5..f58a979c5 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SortFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SortFilterTest.java @@ -2,23 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.testobjects.SortFilterTestObjects; import java.util.Date; import java.util.HashMap; import java.util.Map; - -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; - -public class SortFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class SortFilterTest extends BaseJinjavaTest { @Test public void sortAsc() { @@ -35,6 +28,22 @@ public void sortStringsCaseSensitive() { assertThat(render("(false, true)", "foo", "Foo", "bar")).isEqualTo("Foobarfoo"); } + @Test + public void sortWithNamedAttributes() throws Exception { + // even if named attributes were never supported for this filter, ensure parameters are passed in order and it works + assertThat( + render( + "(reverse=false, case_sensitive=false, attribute='foo.date')", + new SortFilterTestObjects.MyBar(new SortFilterTestObjects.MyFoo(new Date(250L))), + new SortFilterTestObjects.MyBar(new SortFilterTestObjects.MyFoo(new Date(0L))), + new SortFilterTestObjects.MyBar( + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ) + ) + ) + .isEqualTo("0250100000000"); + } + @Test public void sortStringsCaseInsensitive() { assertThat(render("()", "foo", "Foo", "bar")).isEqualTo("barfooFoo"); @@ -42,57 +51,92 @@ public void sortStringsCaseInsensitive() { @Test public void sortWithAttr() { - assertThat(render("(false, false, 'date')", new MyFoo(new Date(250L)), new MyFoo(new Date(0L)), new MyFoo(new Date(100000000L)))).isEqualTo("0250100000000"); + assertThat( + render( + "(false, false, 'date')", + new SortFilterTestObjects.MyFoo(new Date(250L)), + new SortFilterTestObjects.MyFoo(new Date(0L)), + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ) + ) + .isEqualTo("0250100000000"); } @Test public void sortWithNestedAttr() { - assertThat(render("(false, false, 'foo.date')", - new MyBar(new MyFoo(new Date(250L))), new MyBar(new MyFoo(new Date(0L))), new MyBar(new MyFoo(new Date(100000000L))))).isEqualTo("0250100000000"); + assertThat( + render( + "(false, false, 'foo.date')", + new SortFilterTestObjects.MyBar(new SortFilterTestObjects.MyFoo(new Date(250L))), + new SortFilterTestObjects.MyBar(new SortFilterTestObjects.MyFoo(new Date(0L))), + new SortFilterTestObjects.MyBar( + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ) + ) + ) + .isEqualTo("0250100000000"); } - String render(Object... items) { - return render("", items); + @Test + public void itThrowsInvalidArgumentExceptionOnNullAttribute() { + RenderResult result = renderForResult( + "(false, false, null)", + new SortFilterTestObjects.MyFoo(new Date(250L)), + new SortFilterTestObjects.MyFoo(new Date(0L)), + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ); + assertThat(result.getOutput()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.FATAL); + assertThat(result.getErrors().get(0).getMessage()).contains("cannot be null"); } - String render(String sortExtra, Object... items) { - Map context = new HashMap<>(); - context.put("iterable", items); - - return jinjava.render("{% for item in iterable|sort" + sortExtra + " %}{{ item }}{% endfor %}", context); + @Test + public void itThrowsInvalidArgumentWhenObjectAttributeIsNull() { + RenderResult result = renderForResult( + "(false, false, 'doesNotResolve')", + new SortFilterTestObjects.MyFoo(new Date(250L)), + new SortFilterTestObjects.MyFoo(new Date(0L)), + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ); + assertThat(result.getOutput()).isEmpty(); + assertThat(result.getErrors()).hasSize(2); + assertThat(result.getErrors().get(1).getSeverity()).isEqualTo(ErrorType.FATAL); + assertThat(result.getErrors().get(1).getMessage()) + .contains("must be a valid attribute of every item in the list"); } - public static class MyFoo { - private Date date; - - public MyFoo(Date date) { - this.date = date; - } - - public Date getDate() { - return date; - } - - @Override - public String toString() { - return "" + date.getTime(); - } + @Test + public void itThrowsInvalidInputWhenListContainsNull() { + RenderResult result = renderForResult( + "(false, false)", + new SortFilterTestObjects.MyFoo(new Date(250L)), + new SortFilterTestObjects.MyFoo(new Date(0L)), + null, + new SortFilterTestObjects.MyFoo(new Date(100000000L)) + ); + assertThat(result.getOutput()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.FATAL); + assertThat(result.getErrors().get(0).getMessage()) + .contains("cannot contain a null item"); } - public static class MyBar { - private MyFoo foo; + String render(Object... items) { + return render("", items); + } - public MyBar(MyFoo foo) { - this.foo = foo; - } + String render(String sortExtra, Object... items) { + return renderForResult(sortExtra, items).getOutput(); + } - public MyFoo getFoo() { - return foo; - } + RenderResult renderForResult(String sortExtra, Object... items) { + Map context = new HashMap<>(); + context.put("iterable", items); - @Override - public String toString() { - return foo.toString(); - } + return jinjava.renderForResult( + "{% for item in iterable|sort" + sortExtra + " %}{{ item }}{% endfor %}", + context + ); } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SplitFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SplitFilterTest.java index 43b829ed5..b1c2bc424 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SplitFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SplitFilterTest.java @@ -2,22 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseInterpretingTest; import java.util.List; - import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -@RunWith(MockitoJUnitRunner.class) @SuppressWarnings("unchecked") -public class SplitFilterTest { +public class SplitFilterTest extends BaseInterpretingTest { - @Mock - JinjavaInterpreter interpreter; SplitFilter filter; @Before @@ -27,20 +19,52 @@ public void setup() { @Test public void itDefaultsToSpaceSep() { - List result = (List) filter.filter("hello world this is fred", interpreter); + List result = (List) filter.filter( + "hello world this is fred", + interpreter + ); assertThat(result).containsExactly("hello", "world", "this", "is", "fred"); } @Test public void itUsesDifferentSeparatorIfSpecified() { - List result = (List) filter.filter("hello world, this is fred", interpreter, ","); + List result = (List) filter.filter( + "hello world, this is fred", + interpreter, + "," + ); assertThat(result).containsExactly("hello world", "this is fred"); } @Test public void itLimitsResultIfSpecified() { - List result = (List) filter.filter("hello world this is fred", interpreter, " ", "2"); + List result = (List) filter.filter( + "hello world this is fred", + interpreter, + " ", + "2" + ); assertThat(result).containsExactly("hello", "world this is fred"); } + @Test + public void itReturnsDefaultIfSeparatorIsNull() { + List result = (List) filter.filter( + "hello world this is fred", + interpreter, + null + ); + assertThat(result).containsExactly("hello", "world", "this", "is", "fred"); + } + + @Test + public void itReturnsDefaultSeparatorIfNullAndTruncated() { + List result = (List) filter.filter( + "hello world this is fred", + interpreter, + null, + "2" + ); + assertThat(result).containsExactly("hello", "world this is fred"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/StringToTimeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/StringToTimeFilterTest.java new file mode 100644 index 000000000..483a9f296 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/StringToTimeFilterTest.java @@ -0,0 +1,41 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class StringToTimeFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itConvertsStringToTime() { + String datetime = "2018-07-14T14:31:30+0530"; + String format = "yyyy-MM-dd'T'HH:mm:ssZ"; + + Map vars = ImmutableMap.of("test", datetime, "format", format); + + assertThat(jinjava.render("{{ test|strtotime(format)|unixtimestamp }}", vars)) + .isEqualTo("1531558890000"); + } + + @Test + public void itErrorsOnNonStringInput() { + int datetime = 123123; + String format = "yyyy-MM-dd'T'HH:mm:ssZ"; + + Map vars = ImmutableMap.of("datetime", datetime, "format", format); + + assertThat( + jinjava.renderForResult("{{ datetime|strtotime(format) }}", vars).getErrors() + ) + .hasSize(1); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/StripTagsFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/StripTagsFilterTest.java index 1f601b90e..52084ce2b 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/StripTagsFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/StripTagsFilterTest.java @@ -1,31 +1,46 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.anyString; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.internal.stubbing.answers.ReturnsArgumentAt; -import org.mockito.runners.MockitoJUnitRunner; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class StripTagsFilterTest { - @Mock - JinjavaInterpreter interpreter; + private JinjavaInterpreter interpreter; @InjectMocks - StripTagsFilter filter; + private StripTagsFilter filter; @Before public void setup() { - when(interpreter.renderString(anyString())).thenAnswer(new ReturnsArgumentAt(0)); + JinjavaConfig config = BaseJinjavaTest.newConfigBuilder().build(); + Jinjava jinjava = new Jinjava(config); + this.interpreter = new JinjavaInterpreter(jinjava.newInterpreter()); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); } @Test @@ -39,17 +54,91 @@ public void itPassesThruNonStringVals() throws Exception { @Test public void itWorksWithNonHtmlStrings() throws Exception { assertThat(filter.filter("foo", interpreter)).isEqualTo("foo"); - assertThat(filter.filter("foo < bar", interpreter)).isEqualTo("foo < bar"); + assertThat(filter.filter("foo < bar", interpreter)).isEqualTo("foo < bar"); } @Test public void itNormalizesWhitespaceInNonHtmlStrings() throws Exception { - assertThat(filter.filter("foo bar other var", interpreter)).isEqualTo("foo bar other var"); + assertThat(filter.filter("foo bar other var", interpreter)) + .isEqualTo("foo bar other var"); } @Test public void itStripsTagsFromHtml() throws Exception { - assertThat(filter.filter("foo bar other", interpreter)).isEqualTo("foo bar other"); + assertThat(filter.filter("foo bar other", interpreter)) + .isEqualTo("foo bar other"); + } + + @Test + public void itStripsTagsFromNestedHtml() throws Exception { + assertThat(filter.filter("

test
", interpreter)) + .isEqualTo("test"); } + @Test + public void itStripsTagsFromEscapedHtml() throws Exception { + assertThat(filter.filter("<div>test</test>", interpreter)) + .isEqualTo("test"); + } + + @Test + public void itPreservesBreaks() throws Exception { + assertThat(filter.filter("

Test!

Space

", interpreter)) + .isEqualTo("Test! Space"); + } + + @Test + public void itConvertsNewlinesToSpaces() throws Exception { + assertThat(filter.filter("

Test!\n\nSpace

", interpreter)) + .isEqualTo("Test! Space"); + } + + @Test + public void itHandlesNonBreakSpaces() { + assertThat(filter.filter("Test Value", interpreter)).isEqualTo("Test Value"); + } + + @Test + public void itAddsWhitespaceBetweenParagraphTags() { + assertThat(filter.filter("

Test

Value

", interpreter)) + .isEqualTo("Test Value"); + } + + @Test + public void itExecutesJinjavaInsideTag() { + assertThat( + filter.filter("{% for i in [1, 2, 3] %}
{{i}}
{% endfor %}", interpreter) + ) + .isEqualTo("1 2 3"); + } + + @Test + public void itIsolatesJinjavaScopeWhenExecutingCodeInsideTag() { + filter.filter("{% set test = 'hello' %}", interpreter); + assertThat(interpreter.getContext().get("test")).isNull(); + } + + @Test + public void itThrowsDeferredValueExceptionWhenDeferredTokensAreLeft() { + AtomicInteger counter = new AtomicInteger(); + JinjavaInterpreter mockedInterpreter = mock(JinjavaInterpreter.class); + Context mockedContext = mock(Context.class); + when(mockedInterpreter.getContext()).thenReturn(mockedContext); + when(mockedContext.getDeferredTokens()) + .thenAnswer(i -> + counter.getAndIncrement() == 0 + ? Collections.emptySet() + : Collections.singleton( + DeferredToken + .builderFromImage( + "{{ deferred && other }}", + ExpressionToken.class, + interpreter + ) + .build() + ) + ); + assertThatThrownBy(() -> filter.filter("{{ deferred && other }}", mockedInterpreter)) + .isInstanceOf(DeferredValueException.class); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SumFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SumFilterTest.java index 9129a67f4..5aea7bdd9 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SumFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SumFilterTest.java @@ -2,31 +2,22 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; import java.util.HashMap; import java.util.Map; - -import org.junit.Before; import org.junit.Test; -import com.google.common.collect.Lists; -import com.hubspot.jinjava.Jinjava; - @SuppressWarnings("unchecked") -public class SumFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class SumFilterTest extends BaseJinjavaTest { @Test public void sumWithAttr() { Map context = new HashMap<>(); context.put("items", Lists.newArrayList(new Item(12), new Item(30.50))); - assertThat(jinjava.render("{{ items|sum(attribute='price') }}", context)).isEqualTo("42.5"); + assertThat(jinjava.render("{{ items|sum(attribute='price') }}", context)) + .isEqualTo("42.5"); } @Test @@ -34,7 +25,8 @@ public void sumWithAttrAndStart() { Map context = new HashMap<>(); context.put("items", Lists.newArrayList(new Item(12), new Item(30.50))); - assertThat(jinjava.render("{{ items|sum(attribute='price', 10) }}", context)).isEqualTo("52.5"); + assertThat(jinjava.render("{{ items|sum(attribute='price', 10) }}", context)) + .isEqualTo("52.5"); } @Test @@ -46,6 +38,7 @@ public void sumOfSeq() { } public static class Item { + private Number price; public Item(Number price) { diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilterTest.java new file mode 100644 index 000000000..29323ab53 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SymmetricDifferenceFilterTest.java @@ -0,0 +1,42 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class SymmetricDifferenceFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itComputesSetDifferences() { + assertThat( + jinjava.render( + "{{ [1, 2, 3, 3, 4]|symmetric_difference([1, 2, 5, 6]) }}", + new HashMap<>() + ) + ) + .isEqualTo("[3, 4, 5, 6]"); + assertThat( + jinjava.render( + "{{ ['do', 'ray']|symmetric_difference(['ray', 'me']) }}", + new HashMap<>() + ) + ) + .isEqualTo("['do', 'me']"); + } + + @Test + public void itReturnsEmptyOnNullParameters() { + assertThat( + jinjava.render("{{ [1, 2, 3]|symmetric_difference(null) }}", new HashMap<>()) + ) + .isEqualTo("[1, 2, 3]"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TitleFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TitleFilterTest.java index d46b37230..a0c89fb39 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TitleFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TitleFilterTest.java @@ -7,8 +7,44 @@ public class TitleFilterTest { @Test - public void testTitleCase() { - assertThat(new TitleFilter().filter("this is string", null)).isEqualTo("This Is String"); + public void itTitleCasesNormalString() { + assertThat(new TitleFilter().filter("this is string", null)) + .isEqualTo("This Is String"); } + @Test + public void itPreservesWhitespace() { + assertThat(new TitleFilter().filter("this is string ", null)) + .isEqualTo("This Is String "); + } + + @Test + public void itDoesNotChangeAlreadyTitleCasedString() { + assertThat(new TitleFilter().filter("This Is String", null)) + .isEqualTo("This Is String"); + } + + @Test + public void itLowercasesOtherUppercasedCharactersInString() { + assertThat(new TitleFilter().filter("this is sTRING", null)) + .isEqualTo("This Is String"); + } + + @Test + public void itIgnoresParenthesesWhenCapitalizing() { + assertThat(new TitleFilter().filter("test (company) name", null)) + .isEqualTo("Test (Company) Name"); + } + + @Test + public void itIgnoresMultipleSpecialCharactersWhenCapitalizing() { + assertThat(new TitleFilter().filter("@@@@mcoley t@est !@#$%^&*()_+plop", null)) + .isEqualTo("@@@@Mcoley T@est !@#$%^&*()_+Plop"); + } + + @Test + public void itRespectsNewlinesAndTabs() { + assertThat(new TitleFilter().filter("test\t(company)\nname", null)) + .isEqualTo("Test\t(Company)\nName"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ToJsonFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ToJsonFilterTest.java new file mode 100644 index 000000000..5dd406f73 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ToJsonFilterTest.java @@ -0,0 +1,61 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Java6Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class ToJsonFilterTest extends BaseInterpretingTest { + + private ToJsonFilter filter; + + @Before + public void setup() { + filter = new ToJsonFilter(); + } + + @Test + public void itWritesObjectAsString() { + int[] testArray = new int[] { 4, 1, 2 }; + assertThat(filter.filter(testArray, interpreter)).isEqualTo("[4,1,2]"); + + Map testMap = new LinkedHashMap<>(); + testMap.put("testArray", testArray); + testMap.put("testString", "testString"); + assertThat(filter.filter(testMap, interpreter)) + .isEqualTo("{\"testArray\":[4,1,2],\"testString\":\"testString\"}"); + } + + @Test + public void itLimitsLength() { + List> original = new ArrayList<>(); + List> temp = original; + for (int i = 0; i < 100; i++) { + List> nested = new ArrayList<>(); + temp.add(nested); + temp = nested; + } + interpreter = + new Jinjava(BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(500).build()) + .newInterpreter(); + assertThat(filter.filter(original, interpreter)).asString().contains("[[]]]]"); + for (int i = 0; i < 400; i++) { + List> nested = new ArrayList<>(); + temp.add(nested); + temp = nested; + } + try { + filter.filter(original, interpreter); + } catch (Exception e) { + assertThat(e).isInstanceOf(OutputTooBigException.class); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/ToYamlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/ToYamlFilterTest.java new file mode 100644 index 000000000..16b27de62 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/ToYamlFilterTest.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Java6Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class ToYamlFilterTest extends BaseInterpretingTest { + + private ToYamlFilter filter; + + @Before + public void setup() { + filter = new ToYamlFilter(); + } + + @Test + public void itWritesObjectAsString() { + int[] testArray = new int[] { 4, 1, 2 }; + assertThat(filter.filter(testArray, interpreter)) + .isEqualTo("- 4\n" + "- 1\n" + "- 2\n"); + + Map testMap = new LinkedHashMap<>(); + testMap.put("testArray", testArray); + testMap.put("testString", "testString"); + assertThat(filter.filter(testMap, interpreter)) + .isEqualTo( + "testArray:\n" + "- 4\n" + "- 1\n" + "- 2\n" + "testString: \"testString\"\n" + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TrimFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TrimFilterTest.java index 26c867b9a..a45c7558c 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TrimFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TrimFilterTest.java @@ -2,20 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.SafeString; import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +public class TrimFilterTest extends BaseInterpretingTest { -public class TrimFilterTest { - - JinjavaInterpreter interpreter; TrimFilter filter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); filter = new TrimFilter(); } @@ -24,4 +21,24 @@ public void testTrim() { assertThat(filter.filter(" foo ", interpreter)).isEqualTo("foo"); } + @Test + public void testTrimSafeString() { + assertThat(filter.filter(new SafeString(" foo "), interpreter).toString()) + .isEqualTo("foo"); + assertThat(filter.filter(new SafeString(" foo "), interpreter)) + .isInstanceOf(SafeString.class); + } + + @Test + public void itTrimsObject() { + assertThat(filter.filter(new TestObject(), interpreter)).isEqualTo("hello"); + } + + private class TestObject { + + @Override + public String toString() { + return " hello "; + } + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateFilterTest.java index d6065c5e4..944ec93cc 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateFilterTest.java @@ -1,34 +1,24 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.anyString; -import static org.mockito.Mockito.when; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import org.apache.commons.lang3.StringUtils; -import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.internal.stubbing.answers.ReturnsArgumentAt; -import org.mockito.runners.MockitoJUnitRunner; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class TruncateFilterTest { @Mock JinjavaInterpreter interpreter; + @InjectMocks TruncateFilter filter; - @Before - public void setup() { - when(interpreter.resolveString(anyString(), anyInt())).thenAnswer(new ReturnsArgumentAt(0)); - } - @Test public void itPassesThroughSmallEnoughText() throws Exception { String s = StringUtils.rightPad("", 255, 'x'); @@ -37,7 +27,13 @@ public void itPassesThroughSmallEnoughText() throws Exception { @Test public void itTruncatesText() throws Exception { - assertThat(filter.filter(StringUtils.rightPad("", 256, 'x') + "y", interpreter, "255", "True").toString()).hasSize(258).endsWith("x..."); + assertThat( + filter + .filter(StringUtils.rightPad("", 256, 'x') + "y", interpreter, "255", "True") + .toString() + ) + .hasSize(258) + .endsWith("x..."); } @Test @@ -52,7 +48,12 @@ public void itDiscardsLastWordWhenKillwordsFalse() throws Exception { @Test public void itTruncatesWithDifferentEndingIfSpecified() throws Exception { - assertThat(filter.filter("foo bar", interpreter, "5", "True", "!")).isEqualTo("foo b!"); + assertThat(filter.filter("foo bar", interpreter, "5", "True", "!")) + .isEqualTo("foo b!"); } + @Test + public void itTruncatesGivenNegativeLength() throws Exception { + assertThat(filter.filter("foo bar", interpreter, "-1", "True")).isEqualTo("..."); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java index f8440374c..963bc2e85 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java @@ -1,49 +1,88 @@ package com.hubspot.jinjava.lib.filter; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; import java.io.IOException; import java.nio.charset.StandardCharsets; - import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - -public class TruncateHtmlFilterTest { +public class TruncateHtmlFilterTest extends BaseInterpretingTest { TruncateHtmlFilter filter; - JinjavaInterpreter interpreter; @Before public void setup() { filter = new TruncateHtmlFilter(); - interpreter = mock(JinjavaInterpreter.class); } @Test public void itPreservesEndTagsWhenTruncatingWithinTagContent() { - String result = (String) filter.filter(fixture("filter/truncatehtml/long-content-with-tags.html"), interpreter, "33"); - assertThat(result).isEqualTo("

HTML Ipsum Presents

\n

Pellentesque...

"); + String result = (String) filter.filter( + fixture("filter/truncatehtml/long-content-with-tags.html"), + interpreter, + "33" + ); + assertThat(result) + .isEqualTo("

HTML Ipsum Presents

\n

Pellentesque...

"); } @Test public void itDoesntChopWordsWhenSpecified() { - String result = (String) filter.filter(fixture("filter/truncatehtml/long-content-with-tags.html"), interpreter, "35"); - assertThat(result).isEqualTo("

HTML Ipsum Presents

\n

Pellentesque...

"); + String result = (String) filter.filter( + fixture("filter/truncatehtml/long-content-with-tags.html"), + interpreter, + "35" + ); + assertThat(result) + .isEqualTo("

HTML Ipsum Presents

\n

Pellentesque...

"); + + result = + (String) filter.filter( + fixture("filter/truncatehtml/long-content-with-tags.html"), + interpreter, + "35", + "...", + "true" + ); + assertThat(result) + .isEqualTo( + "

HTML Ipsum Presents

\n

Pellentesque ha...

" + ); + } + + @Test + public void itTakesKwargs() { + String result = (String) filter.filter( + fixture("filter/truncatehtml/long-content-with-tags.html"), + interpreter, + new Object[] { "35" }, + ImmutableMap.of("breakwords", false) + ); + assertThat(result) + .isEqualTo("

HTML Ipsum Presents

\n

Pellentesque...

"); - result = (String) filter.filter(fixture("filter/truncatehtml/long-content-with-tags.html"), interpreter, "35", "...", "true"); - assertThat(result).isEqualTo("

HTML Ipsum Presents

\n

Pellentesque ha...

"); + result = + (String) filter.filter( + fixture("filter/truncatehtml/long-content-with-tags.html"), + interpreter, + new Object[] { "35" }, + ImmutableMap.of("end", "TEST") + ); + assertThat(result) + .isEqualTo( + "

HTML Ipsum Presents

\n

PellentesqueTEST

" + ); } private static String fixture(String name) { try { return Resources.toString(Resources.getResource(name), StandardCharsets.UTF_8); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilterTest.java new file mode 100644 index 000000000..1d4b12a82 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UnescapeHtmlFilterTest.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import org.junit.Before; +import org.junit.Test; + +public class UnescapeHtmlFilterTest extends BaseInterpretingTest { + + UnescapeHtmlFilter f; + + @Before + public void setup() { + f = new UnescapeHtmlFilter(); + } + + @Test + public void itUnescapes() { + assertThat(f.filter("", interpreter)).isEqualTo(""); + assertThat(f.filter("me & you", interpreter)).isEqualTo("me & you"); + assertThat(f.filter("jeff's & jack's bogüs journey", interpreter)) + .isEqualTo("jeff's & jack's bogüs journey"); + assertThat(f.filter(1, interpreter)).isEqualTo("1"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UnionFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UnionFilterTest.java new file mode 100644 index 000000000..5188b270d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UnionFilterTest.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class UnionFilterTest extends BaseJinjavaTest { + + @Before + public void setup() { + jinjava.getGlobalContext().registerClasses(EscapeJsFilter.class); + } + + @Test + public void itComputesSetUnions() { + assertThat(jinjava.render("{{ [1, 2, 3, 3]|union([1, 2, 5, 6]) }}", new HashMap<>())) + .isEqualTo("[1, 2, 3, 5, 6]"); + assertThat( + jinjava.render("{{ ['do', 'ray']|union(['ray', 'me']) }}", new HashMap<>()) + ) + .isEqualTo("['do', 'ray', 'me']"); + } + + @Test + public void itReturnsSetOnNullParameters() { + assertThat(jinjava.render("{{ [1, 2, 3, 3]|union(null) }}", new HashMap<>())) + .isEqualTo("[1, 2, 3]"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UniqueFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UniqueFilterTest.java index d09ad00a4..ef924bc3f 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/UniqueFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UniqueFilterTest.java @@ -2,23 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.testobjects.UniqueFilterTestObjects; import java.util.HashMap; import java.util.Map; - import org.apache.commons.lang3.StringUtils; -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; - -public class UniqueFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class UniqueFilterTest extends BaseJinjavaTest { @Test public void itDoesntFilterWhenNoDuplicateItemsInSeq() { @@ -32,8 +23,16 @@ public void itFiltersDuplicatesFromSeq() { @Test public void itFiltersDuplicatesFromSeqByAttr() { - assertThat(render("name", new MyClass("a"), new MyClass("b"), new MyClass("a"), new MyClass("c"))) - .isEqualTo("[Name:a][Name:b][Name:c]"); + assertThat( + render( + "name", + new UniqueFilterTestObjects.MyClass("a"), + new UniqueFilterTestObjects.MyClass("b"), + new UniqueFilterTestObjects.MyClass("a"), + new UniqueFilterTestObjects.MyClass("c") + ) + ) + .isEqualTo("[Name:a][Name:b][Name:c]"); } String render(Object... items) { @@ -49,24 +48,9 @@ String render(String attr, Object... items) { attrExtra = "(attr='" + attr + "')"; } - return jinjava.render("{% for item in iterable|unique" + attrExtra + " %}{{ item }}{% endfor %}", context); - } - - public static class MyClass { - private final String name; - - public MyClass(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - @Override - public String toString() { - return "[Name:" + name + "]"; - } + return jinjava.render( + "{% for item in iterable|unique" + attrExtra + " %}{{ item }}{% endfor %}", + context + ); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilterTest.java new file mode 100644 index 000000000..7d4af1373 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UnixTimestampFilterTest.java @@ -0,0 +1,89 @@ +package com.hubspot.jinjava.lib.filter; + +import static com.hubspot.jinjava.lib.filter.time.DateTimeFormatHelper.FIXED_DATE_TIME_FILTER_NULL_ARG; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.time.ZonedDateTime; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class UnixTimestampFilterTest extends BaseInterpretingTest { + + private final ZonedDateTime d = ZonedDateTime.parse( + "2013-11-06T14:22:00.000+00:00[UTC]" + ); + private final String timestamp = Long.toString(d.toEpochSecond() * 1000); + + @Before + public void setup() { + interpreter.getContext().put("d", d); + } + + @After + public void tearDown() { + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itRendersFromDate() { + assertThat(interpreter.renderFlat("{{ d|unixtimestamp }}")).isEqualTo(timestamp); + } + + @Test + public void itDefaultsToCurrentDate() { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(() -> d.toEpochSecond() * 1000) + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add(FIXED_DATE_TIME_FILTER_NULL_ARG, DateTimeFeatureActivationStrategy.of(d)) + .build() + ) + .build() + ); + + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + + try { + assertThat(jinjava.render("{{ null | unixtimestamp }}", ImmutableMap.of())) + .isEqualTo(timestamp); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itDefaultsToDeprecationDate() { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(() -> d.toEpochSecond() * 1000) + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add(FIXED_DATE_TIME_FILTER_NULL_ARG, DateTimeFeatureActivationStrategy.of(d)) + .build() + ) + .build() + ); + + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + + try { + assertThat(jinjava.render("{{ null | unixtimestamp }}", ImmutableMap.of())) + .isEqualTo("1383747720000"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UpperFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UpperFilterTest.java new file mode 100644 index 000000000..1c0461f44 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UpperFilterTest.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.lib.filter; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.objects.SafeString; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Test; + +public class UpperFilterTest extends BaseInterpretingTest { + + private UpperFilter filter; + + @Before + public void setup() { + filter = new UpperFilter(); + } + + @Test + public void testUpper() { + Assertions.assertThat(filter.filter("lower", interpreter)).isEqualTo("LOWER"); + } + + @Test + public void testUpperSafeString() { + Assertions + .assertThat(filter.filter(new SafeString("lower"), interpreter).toString()) + .isEqualTo("LOWER"); + Assertions + .assertThat(filter.filter(new SafeString("lower"), interpreter)) + .isInstanceOf(SafeString.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilterTest.java new file mode 100644 index 000000000..96914cf91 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UrlDecodeFilterTest.java @@ -0,0 +1,39 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class UrlDecodeFilterTest extends BaseInterpretingTest { + + private UrlDecodeFilter filter; + + @Before + public void setup() { + filter = new UrlDecodeFilter(); + } + + @Test + public void itDecodesDictAsParamPairs() { + Map dict = new LinkedHashMap<>(); + dict.put("foo", "bar%3Dset"); + dict.put("other", "val"); + + assertThat(filter.filter(dict, interpreter)).isEqualTo("foo=bar=set&other=val"); + } + + @Test + public void itDecodesVarString() { + assertThat(filter.filter("http%3A%2F%2Ffoo.com%3Fbar%26food", interpreter)) + .isEqualTo("http://foo.com?bar&food"); + } + + @Test + public void itDecodesArgWhenNoVar() { + assertThat(filter.filter(null, interpreter, "foo%26you")).isEqualTo("foo&you"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilterTest.java index 9c9312162..f2021c064 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UrlEncodeFilterTest.java @@ -2,23 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseInterpretingTest; import java.util.LinkedHashMap; import java.util.Map; - import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - -public class UrlEncodeFilterTest { +public class UrlEncodeFilterTest extends BaseInterpretingTest { - JinjavaInterpreter interpreter; UrlEncodeFilter filter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); filter = new UrlEncodeFilter(); } @@ -33,12 +28,12 @@ public void itEncodesDictAsParamPairs() { @Test public void itEncodesVarString() { - assertThat(filter.filter("http://foo.com?bar&food", interpreter)).isEqualTo("http%3A%2F%2Ffoo.com%3Fbar%26food"); + assertThat(filter.filter("http://foo.com?bar&food", interpreter)) + .isEqualTo("http%3A%2F%2Ffoo.com%3Fbar%26food"); } @Test public void itEncodesArgWhenNoVar() { assertThat(filter.filter(null, interpreter, "foo&you")).isEqualTo("foo%26you"); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/UrlizeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/UrlizeFilterTest.java index 0f4dcffcb..7fd3fe2b1 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/UrlizeFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/UrlizeFilterTest.java @@ -2,34 +2,38 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; import java.nio.charset.StandardCharsets; import java.util.HashMap; - import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.junit.Before; import org.junit.Test; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; - -public class UrlizeFilterTest { - - Jinjava jinjava; +public class UrlizeFilterTest extends BaseJinjavaTest { @Before public void setup() throws Exception { - jinjava = new Jinjava(); - jinjava.getGlobalContext().put("txt", Resources.toString(Resources.getResource("filter/urlize.txt"), StandardCharsets.UTF_8)); + jinjava + .getGlobalContext() + .put( + "txt", + Resources.toString( + Resources.getResource("filter/urlize.txt"), + StandardCharsets.UTF_8 + ) + ); } @Test public void urlizeText() { - Document dom = Jsoup.parseBodyFragment(jinjava.render("{{ txt|urlize }}", new HashMap())); + Document dom = Jsoup.parseBodyFragment( + jinjava.render("{{ txt|urlize }}", new HashMap()) + ); assertThat(dom.select("a")).hasSize(3); assertThat(dom.select("a").get(0).attr("href")).isEqualTo("http://www.espn.com"); assertThat(dom.select("a").get(1).attr("href")).isEqualTo("http://yahoo.com"); assertThat(dom.select("a").get(2).attr("href")).isEqualTo("https://hubspot.com"); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/WordCountFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/WordCountFilterTest.java index e3e906d4f..42c42073c 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/WordCountFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/WordCountFilterTest.java @@ -21,7 +21,7 @@ public void singleWord() { @Test public void multiWords() { - assertThat(filter.filter("this is foo.\nfoo is coo. hooray for foo.", null)).isEqualTo(9); + assertThat(filter.filter("this is foo.\nfoo is coo. hooray for foo.", null)) + .isEqualTo(9); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/XmlAttrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/XmlAttrFilterTest.java index fbeba08a9..13f9a6bc7 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/XmlAttrFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/XmlAttrFilterTest.java @@ -2,36 +2,52 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableList; +import com.hubspot.jinjava.BaseJinjavaTest; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; - import org.jsoup.Jsoup; import org.jsoup.nodes.Document; -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; - -public class XmlAttrFilterTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class XmlAttrFilterTest extends BaseJinjavaTest { @Test public void testXmlAttr() { Map context = new HashMap<>(); context.put("variable", 42); - Document dom = Jsoup.parseBodyFragment(jinjava.render( - "", context)); + Document dom = Jsoup.parseBodyFragment( + jinjava.render( + "", + context + ) + ); assertThat(dom.select("ul").attr("class")).isEqualTo("my_list"); assertThat(dom.select("ul").attr("id")).isEqualTo("list-42"); assertThat(dom.select("ul").attr("missing")).isEmpty(); } + @Test + public void itDoesNotAllowInvalidKeys() { + List invalidStrings = ImmutableList.of("\t", "\n", "\f", " ", "/", ">", "="); + invalidStrings.forEach(invalidString -> + assertThat( + jinjava + .renderForResult( + String.format("{{ {'%s': 'foo'}|xmlattr }}", invalidString), + Collections.emptyMap() + ) + .getErrors() + ) + .matches(templateErrors -> + templateErrors.size() == 1 && + templateErrors.get(0).getException().getCause().getCause() instanceof + IllegalArgumentException + ) + ); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilterTest.java new file mode 100644 index 000000000..65c0b321b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDateFilterTest.java @@ -0,0 +1,297 @@ +package com.hubspot.jinjava.lib.filter.time; + +import static com.hubspot.jinjava.lib.filter.time.DateTimeFormatHelper.FIXED_DATE_TIME_FILTER_NULL_ARG; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.features.DateTimeFeatureActivationStrategy; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Before; +import org.junit.Test; + +public class FormatDateFilterTest { + + private static final ZonedDateTime DATE_TIME = ZonedDateTime.of( + 2022, + 11, + 10, + 22, + 49, + 7, + 0, + ZoneOffset.UTC + ); + + Jinjava jinjava; + + @Before + public void setUp() throws Exception { + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + jinjava.getGlobalContext().registerClasses(FormatDateFilter.class); + } + + @Test + public void itFormatsNumbers() { + assertThat( + jinjava.render("{{ d | format_date }}", ImmutableMap.of("d", 1668120547000L)) + ) + .isEqualTo("Nov 10, 2022"); + } + + @Test + public void itFormatsPyishDates() { + PyishDate pyishDate = new PyishDate(1668120547000L); + + assertThat(jinjava.render("{{ d | format_date }}", ImmutableMap.of("d", pyishDate))) + .isEqualTo("Nov 10, 2022"); + } + + @Test + public void itFormatsZonedDateTime() { + assertThat(jinjava.render("{{ d | format_date }}", ImmutableMap.of("d", DATE_TIME))) + .isEqualTo("Nov 10, 2022"); + } + + @Test + public void itHandlesInvalidDateInput() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date }}", + ImmutableMap.of("d", "nonsense") + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Input to function must be a date object, was: class java.lang.String"); + } + + @Test + public void itUsesShortFormat() { + assertThat( + jinjava.render("{{ d | format_date('short') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isEqualTo("11/10/22"); + } + + @Test + public void itUsesMediumFormat() { + assertThat( + jinjava.render("{{ d | format_date('medium') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isEqualTo("Nov 10, 2022"); + } + + @Test + public void itUsesLongFormat() { + assertThat( + jinjava.render("{{ d | format_date('long') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isEqualTo("November 10, 2022"); + } + + @Test + public void itUsesFullFormat() { + assertThat( + jinjava.render("{{ d | format_date('full') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isEqualTo("Thursday, November 10, 2022"); + } + + @Test + public void itUsesCustomFormats() { + assertThat( + jinjava.render( + "{{ d | format_date('yyyyy.MMMM.dd') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("02022.November.10"); + } + + @Test + public void itHandlesInvalidFormats() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date('fake pattern') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid date format") + .contains("Unknown pattern letter: f"); + } + + @Test + public void itUsesGivenTimeZone() { + assertThat( + jinjava.render( + "{{ d | format_date('long', 'Asia/Jakarta') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("November 11, 2022"); + } + + @Test + public void itHandlesInvalidTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date('long', 'not a real time zone') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid time zone: not a real time zone"); + } + + @Test + public void itHandlesEmptyTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date('long', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid time zone: "); + } + + @Test + public void itUsesGivenLocale() { + assertThat( + jinjava.render( + "{{ d | format_date('medium', 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.2022"); + } + + @Test + public void itHandlesInvalidLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date('medium', 'America/New_York', 'not a real locale') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid locale: not a real locale"); + } + + @Test + public void itHandlesEmptyLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_date('medium', 'America/New_York', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid locale: "); + } + + @Test + public void itUsesMediumIfNullFormatPassed() { + assertThat( + jinjava.render( + "{{ d | format_date(null, 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.2022"); + } + + @Test + public void itUsesUtcIfNullZonePassed() { + assertThat( + jinjava.render( + "{{ d | format_date('short', null, 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.22"); + } + + @Test + public void itUsesJinjavaConfigIfNullLocalePassed() { + assertThat( + jinjava.render( + "{{ d | format_date('short', 'America/New_York', null) }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("11/10/22"); + } + + @Test + public void itWarnsOnMissingFilterArg() { + RenderResult renderResult = jinjava.renderForResult( + "{{ d | format_date('short', 'America/New_York', null) }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(renderResult.getOutput()).isEqualTo("11/10/22"); + + assertThat(renderResult.getErrors()).isEmpty(); + + renderResult = + jinjava.renderForResult( + "{{ d | format_date('short', 'America/New_York', null) }}", + ImmutableMap.of() + ); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getMessage()) + .isEqualTo("format_date filter called with null datetime"); + } + + @Test + public void itDefaultsToCurrentDateOnMissingFilterArg() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(() -> 1233333414223L) + .build() + ); + + assertThat( + jinjava.render( + "{{ d | format_date('short', 'America/New_York', null) }}", + ImmutableMap.of() + ) + ) + .isEqualTo("1/30/09"); + } + + @Test + public void itDefaultsToDeprecationDateOnMissingFilterArg() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(() -> 1233333414223L) + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + FIXED_DATE_TIME_FILTER_NULL_ARG, + DateTimeFeatureActivationStrategy.of(DATE_TIME) + ) + .build() + ) + .build() + ); + + assertThat( + jinjava.render( + "{{ d | format_date('short', 'America/New_York', null) }}", + ImmutableMap.of() + ) + ) + .isEqualTo("11/10/22"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilterTest.java new file mode 100644 index 000000000..c6dbe0aa5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatDatetimeFilterTest.java @@ -0,0 +1,238 @@ +package com.hubspot.jinjava.lib.filter.time; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Before; +import org.junit.Test; + +public class FormatDatetimeFilterTest { + + private static final ZonedDateTime DATE_TIME = ZonedDateTime.of( + 2022, + 11, + 10, + 22, + 49, + 7, + 0, + ZoneOffset.UTC + ); + + Jinjava jinjava; + + @Before + public void setUp() throws Exception { + jinjava = new Jinjava(); + jinjava.getGlobalContext().registerClasses(FormatDatetimeFilter.class); + } + + @Test + public void itFormatsNumbers() { + assertThat( + jinjava.render("{{ d | format_datetime }}", ImmutableMap.of("d", 1668120547000L)) + ) + .isIn("Nov 10, 2022, 10:49:07 PM", "Nov 10, 2022, 10:49:07 PM"); + } + + @Test + public void itFormatsPyishDates() { + PyishDate pyishDate = new PyishDate(1668120547000L); + + assertThat( + jinjava.render("{{ d | format_datetime }}", ImmutableMap.of("d", pyishDate)) + ) + .isIn("Nov 10, 2022, 10:49:07 PM", "Nov 10, 2022, 10:49:07 PM"); + } + + @Test + public void itFormatsZonedDateTime() { + assertThat( + jinjava.render("{{ d | format_datetime }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("Nov 10, 2022, 10:49:07 PM", "Nov 10, 2022, 10:49:07 PM"); + } + + @Test + public void itHandlesInvalidDateInput() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime }}", + ImmutableMap.of("d", "nonsense") + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Input to function must be a date object, was: class java.lang.String"); + } + + @Test + public void itUsesShortFormat() { + assertThat( + jinjava.render( + "{{ d | format_datetime('short') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("11/10/22, 10:49 PM", "11/10/22, 10:49 PM"); + } + + @Test + public void itUsesMediumFormat() { + assertThat( + jinjava.render( + "{{ d | format_datetime('medium') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("Nov 10, 2022, 10:49:07 PM", "Nov 10, 2022, 10:49:07 PM"); + } + + @Test + public void itUsesLongFormat() { + assertThat( + jinjava.render("{{ d | format_datetime('long') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("November 10, 2022 at 10:49:07 PM Z", "November 10, 2022, 10:49:07 PM Z"); + } + + @Test + public void itUsesFullFormat() { + assertThat( + jinjava.render("{{ d | format_datetime('full') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn( + "Thursday, November 10, 2022 at 10:49:07 PM Z", + "Thursday, November 10, 2022, 10:49:07 PM Z" + ); + } + + @Test + public void itUsesCustomFormats() { + assertThat( + jinjava.render( + "{{ d | format_datetime('yyyyy.MMMM.dd GGG hh:mm a') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("02022.November.10 AD 10:49 PM"); + } + + @Test + public void itHandlesInvalidFormats() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime('fake pattern') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid date format") + .contains("Unknown pattern letter: f"); + } + + @Test + public void itUsesGivenTimeZone() { + assertThat( + jinjava.render( + "{{ d | format_datetime('long', 'America/New_York') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("November 10, 2022 at 5:49:07 PM EST", "November 10, 2022, 5:49:07 PM EST"); + } + + @Test + public void itHandlesInvalidTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime('long', 'not a real time zone') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid time zone: not a real time zone"); + } + + @Test + public void itHandlesEmptyTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime('long', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid time zone: "); + } + + @Test + public void itUsesGivenLocale() { + assertThat( + jinjava.render( + "{{ d | format_datetime('medium', 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.2022, 17:49:07"); + } + + @Test + public void itHandlesInvalidLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime('medium', 'America/New_York', 'not a real locale') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid locale: not a real locale"); + } + + @Test + public void itHandlesEmptyLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_datetime('medium', 'America/New_York', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid locale: "); + } + + @Test + public void itUsesMediumIfNullFormatPassed() { + assertThat( + jinjava.render( + "{{ d | format_datetime(null, 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.2022, 17:49:07"); + } + + @Test + public void itUsesUtcIfNullZonePassed() { + assertThat( + jinjava.render( + "{{ d | format_datetime('short', null, 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("10.11.22, 22:49"); + } + + @Test + public void itUsesJinjavaConfigIfNullLocalePassed() { + assertThat( + jinjava.render( + "{{ d | format_datetime('short', 'America/New_York', null) }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("11/10/22, 5:49 PM", "11/10/22, 5:49 PM"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilterTest.java new file mode 100644 index 000000000..300334684 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/time/FormatTimeFilterTest.java @@ -0,0 +1,222 @@ +package com.hubspot.jinjava.lib.filter.time; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Before; +import org.junit.Test; + +public class FormatTimeFilterTest { + + private static final ZonedDateTime DATE_TIME = ZonedDateTime.of( + 2022, + 11, + 10, + 22, + 49, + 7, + 0, + ZoneOffset.UTC + ); + + Jinjava jinjava; + + @Before + public void setUp() throws Exception { + jinjava = new Jinjava(); + jinjava.getGlobalContext().registerClasses(FormatTimeFilter.class); + } + + @Test + public void itFormatsNumbers() { + assertThat( + jinjava.render("{{ d | format_time }}", ImmutableMap.of("d", 1668120547000L)) + ) + .isIn("10:49:07 PM", "10:49:07 PM"); + } + + @Test + public void itFormatsPyishDates() { + PyishDate pyishDate = new PyishDate(1668120547000L); + + assertThat(jinjava.render("{{ d | format_time }}", ImmutableMap.of("d", pyishDate))) + .isIn("10:49:07 PM", "10:49:07 PM"); + } + + @Test + public void itFormatsZonedDateTime() { + assertThat(jinjava.render("{{ d | format_time }}", ImmutableMap.of("d", DATE_TIME))) + .isIn("10:49:07 PM", "10:49:07 PM"); + } + + @Test + public void itHandlesInvalidDateInput() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time }}", + ImmutableMap.of("d", "nonsense") + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Input to function must be a date object, was: class java.lang.String"); + } + + @Test + public void itUsesShortFormat() { + assertThat( + jinjava.render("{{ d | format_time('short') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("10:49 PM", "10:49 PM"); + } + + @Test + public void itUsesMediumFormat() { + assertThat( + jinjava.render("{{ d | format_time('medium') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("10:49:07 PM", "10:49:07 PM"); + } + + @Test + public void itUsesLongFormat() { + assertThat( + jinjava.render("{{ d | format_time('long') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("10:49:07 PM Z", "10:49:07 PM Z"); + } + + @Test + public void itUsesFullFormat() { + assertThat( + jinjava.render("{{ d | format_time('full') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isIn("10:49:07 PM Z", "10:49:07 PM Z"); + } + + @Test + public void itUsesCustomFormats() { + assertThat( + jinjava.render("{{ d | format_time('hh:mm a') }}", ImmutableMap.of("d", DATE_TIME)) + ) + .isEqualTo("10:49 PM"); + } + + @Test + public void itHandlesInvalidFormats() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time('fake pattern') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid date format") + .contains("Unknown pattern letter: f"); + } + + @Test + public void itUsesGivenTimeZone() { + assertThat( + jinjava.render( + "{{ d | format_time('long', 'America/New_York') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("5:49:07 PM EST", "5:49:07 PM EST"); + } + + @Test + public void itHandlesInvalidTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time('long', 'not a real time zone') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid time zone: not a real time zone"); + } + + @Test + public void itHandlesEmptyTimeZones() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time('long', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid time zone: "); + } + + @Test + public void itUsesGivenLocale() { + assertThat( + jinjava.render( + "{{ d | format_time('medium', 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("17:49:07"); + } + + @Test + public void itHandlesInvalidLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time('medium', 'America/New_York', 'not a real locale') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Invalid locale: not a real locale"); + } + + @Test + public void itHandlesEmptyLocales() { + RenderResult result = jinjava.renderForResult( + "{{ d | format_time('medium', 'America/New_York', '') }}", + ImmutableMap.of("d", DATE_TIME) + ); + assertThat(result.getOutput()).isEqualTo(""); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).contains("Invalid locale: "); + } + + @Test + public void itUsesMediumIfNullFormatPassed() { + assertThat( + jinjava.render( + "{{ d | format_time(null, 'America/New_York', 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("17:49:07", "5:49 PM"); + } + + @Test + public void itUsesUtcIfNullZonePassed() { + assertThat( + jinjava.render( + "{{ d | format_time('short', null, 'de-DE') }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isEqualTo("22:49"); + } + + @Test + public void itUsesJinjavaConfigIfNullLocalePassed() { + assertThat( + jinjava.render( + "{{ d | format_time('short', 'America/New_York', null) }}", + ImmutableMap.of("d", DATE_TIME) + ) + ) + .isIn("5:49 PM", "5:49 PM"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/DateFormatFunctionsTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/DateFormatFunctionsTest.java new file mode 100644 index 000000000..9e28eee10 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/DateFormatFunctionsTest.java @@ -0,0 +1,53 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Before; +import org.junit.Test; + +public class DateFormatFunctionsTest { + + Jinjava jinjava; + + @Before + public void setUp() throws Exception { + jinjava = new Jinjava(); + } + + @Test + public void itFormatsDates() { + assertThat( + jinjava.render( + "{{ format_date(d, 'medium') }}", + ImmutableMap.of("d", ZonedDateTime.of(2022, 11, 28, 16, 30, 4, 0, ZoneOffset.UTC)) + ) + ) + .isEqualTo("Nov 28, 2022"); + } + + @Test + public void itFormatsTimes() { + assertThat( + jinjava.render( + "{{ format_time(d, 'medium') }}", + ImmutableMap.of("d", ZonedDateTime.of(2022, 11, 28, 16, 30, 4, 0, ZoneOffset.UTC)) + ) + ) + .isIn("4:30:04 PM", "4:30:04 PM"); + } + + @Test + public void itFormatsDateTimes() { + assertThat( + jinjava.render( + "{{ format_datetime(d, 'medium') }}", + ImmutableMap.of("d", ZonedDateTime.of(2022, 11, 28, 16, 30, 4, 0, ZoneOffset.UTC)) + ) + ) + .isIn("Nov 28, 2022, 4:30:04 PM", "Nov 28, 2022, 4:30:04 PM"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxyTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxyTest.java index 59394008c..6b4c00a0e 100644 --- a/src/test/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxyTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/fn/InjectedContextFunctionProxyTest.java @@ -3,12 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import java.lang.reflect.Method; - import org.junit.Test; public class InjectedContextFunctionProxyTest { public static class MyClass { + private String state; public MyClass(String state) { @@ -20,17 +20,81 @@ public String concatState(String in) { } } + public static class OtherClass { + + private String state; + + public OtherClass(String state) { + this.state = state; + } + + public String prependState(String in) { + return state + in; + } + } + @Test public void testDefineProxy() throws Exception { Method m = MyClass.class.getDeclaredMethod("concatState", String.class); MyClass instance = new MyClass("bar"); - ELFunctionDefinition proxy = InjectedContextFunctionProxy.defineProxy("ns", "fooproxy", m, instance); + ELFunctionDefinition proxy = InjectedContextFunctionProxy.defineProxy( + "ns", + "fooproxy", + m, + instance + ); assertThat(proxy.getName()).isEqualTo("ns:fooproxy"); - assertThat(proxy.getMethod().getDeclaringClass().getSimpleName()).isEqualTo( - InjectedContextFunctionProxy.class.getSimpleName() + "$$ns$$fooproxy"); + assertThat(proxy.getMethod().getDeclaringClass().getName()) + .isEqualTo( + MyClass.class.getName() + + "$$" + + InjectedContextFunctionProxy.class.getSimpleName() + + "$$ns$$fooproxy" + ); assertThat(proxy.getMethod().invoke(null, "foo")).isEqualTo("foobar"); } + @Test + public void testDefineMultipleProxies() throws Exception { + Method concat = MyClass.class.getDeclaredMethod("concatState", String.class); + MyClass myClassInstance = new MyClass("bar"); + + ELFunctionDefinition myClassProxy = InjectedContextFunctionProxy.defineProxy( + "ns", + "fooproxy", + concat, + myClassInstance + ); + Method prepend = OtherClass.class.getDeclaredMethod("prependState", String.class); + OtherClass otherClassInstance = new OtherClass("bar"); + + ELFunctionDefinition otherClassProxy = InjectedContextFunctionProxy.defineProxy( + "ns", + "fooproxy", + prepend, + otherClassInstance + ); + assertThat(myClassProxy.getName()).isEqualTo("ns:fooproxy"); + assertThat(myClassProxy.getMethod().getDeclaringClass().getName()) + .isEqualTo( + MyClass.class.getName() + + "$$" + + InjectedContextFunctionProxy.class.getSimpleName() + + "$$ns$$fooproxy" + ); + + assertThat(myClassProxy.getMethod().invoke(null, "foo")).isEqualTo("foobar"); + assertThat(otherClassProxy.getName()).isEqualTo("ns:fooproxy"); + assertThat(otherClassProxy.getMethod().getDeclaringClass().getName()) + .isEqualTo( + OtherClass.class.getName() + + "$$" + + InjectedContextFunctionProxy.class.getSimpleName() + + "$$ns$$fooproxy" + ); + + assertThat(otherClassProxy.getMethod().invoke(null, "foo")).isEqualTo("barfoo"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/RangeFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/RangeFunctionTest.java new file mode 100644 index 000000000..207d04c3f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/RangeFunctionTest.java @@ -0,0 +1,104 @@ +package com.hubspot.jinjava.lib.fn; + +import static com.hubspot.jinjava.interpret.JinjavaInterpreter.pushCurrent; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.Arrays; +import java.util.Collections; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class RangeFunctionTest { + + private JinjavaConfig config; + + @Before + public void beforeEach() { + config = BaseJinjavaTest.newConfigBuilder().build(); + Jinjava jinjava = new Jinjava(config); + pushCurrent(new JinjavaInterpreter(jinjava.newInterpreter())); + } + + @After + public void afterEach() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void interpreterInstanceIsMandatory() { + JinjavaInterpreter.popCurrent(); + assertThatThrownBy(() -> Functions.range(1)).isInstanceOf(NullPointerException.class); + } + + @Test + public void itGeneratesSimpleRanges() { + assertThat(Functions.range(1)).isEqualTo(Collections.singletonList(0)); + assertThat(Functions.range(2)).isEqualTo(Arrays.asList(0, 1)); + assertThat(Functions.range(2, 4)).isEqualTo(Arrays.asList(2, 3)); + assertThat(Functions.range("2", "4")).isEqualTo(Arrays.asList(2, 3)); + assertThat(Functions.range(2, 8, 2)).isEqualTo(Arrays.asList(2, 4, 6)); + } + + @Test + public void itGeneratesBackwardsRanges() { + assertThat(Functions.range(2, -1, -1)).isEqualTo(Arrays.asList(2, 1, 0)); + assertThat(Functions.range(8, 2, -2)).isEqualTo(Arrays.asList(8, 6, 4)); + assertThat(Functions.range(2, -1, "-1")).isEqualTo(Arrays.asList(2, 1, 0)); + } + + @Test + public void itHandlesBadRanges() { + assertThat(Functions.range(-2)).isEmpty(); + assertThat(Functions.range(-2, -4)).isEmpty(); + assertThat(Functions.range(-2, -2)).isEmpty(); + assertThat(Functions.range(2, 2)).isEmpty(); + assertThat(Functions.range(2, 2000, 0)).isEmpty(); + assertThat(Functions.range(2, 2000, -5)).isEmpty(); + } + + @Test + public void itHandlesBadValues() { + assertThat(Functions.range("f")).isEmpty(); + assertThat(Functions.range(2, "f")).isEmpty(); + assertThat(Functions.range(2, new Object[] { null })).isEmpty(); + assertThat(Functions.range(2, 4, null)).isEqualTo(Arrays.asList(2, 3)); + } + + @Test + public void itHandlesMissingArg() { + assertThatThrownBy(() -> Functions.range(null)) + .isInstanceOf(InvalidArgumentException.class); + } + + @Test + public void itTruncatesRangeToDefaultRangeLimit() { + int defaultRangeLimit = config.getRangeLimit(); + assertThat(defaultRangeLimit).isEqualTo(Functions.DEFAULT_RANGE_LIMIT); + assertThat(Functions.range(2, 200000000).size()).isEqualTo(defaultRangeLimit); + assertThat(Functions.range(Long.MAX_VALUE - 1, Long.MAX_VALUE).size()) + .isEqualTo(defaultRangeLimit); + } + + @Test + public void itTruncatesRangeToCustomRangeLimit() { + JinjavaInterpreter.popCurrent(); + int customRangeLimit = 10; + JinjavaConfig customConfig = BaseJinjavaTest + .newConfigBuilder() + .withRangeLimit(customRangeLimit) + .build(); + pushCurrent(new JinjavaInterpreter(new Jinjava(customConfig).newInterpreter())); + assertThat(customConfig.getRangeLimit()).isEqualTo(customRangeLimit); + + assertThat(Functions.range(20).size()).isEqualTo(customRangeLimit); + assertThat(Functions.range(Long.MAX_VALUE - 1, Long.MAX_VALUE).size()) + .isEqualTo(customRangeLimit); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/StringToDateFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/StringToDateFunctionTest.java new file mode 100644 index 000000000..29b4a58ae --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/StringToDateFunctionTest.java @@ -0,0 +1,50 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.LocalDate; +import java.time.ZoneOffset; +import org.junit.Test; + +public class StringToDateFunctionTest { + + @Test + public void itConvertsStringToDate() { + String dateString = "3/4/21"; + String format = "M/d/yy"; + PyishDate expected = new PyishDate( + LocalDate.of(2021, 3, 4).atTime(0, 0).toInstant(ZoneOffset.UTC) + ); + assertThat(Functions.stringToDate(dateString, format)).isEqualTo(expected); + } + + @Test + public void itFailsOnInvalidFormat() { + String dateString = "3/4/21"; + String format = "blah blah"; + + assertThatThrownBy(() -> Functions.stringToDate(dateString, format)) + .isInstanceOf(InterpretException.class); + } + + @Test + public void itFailsOnTimeFormatMismatch() { + String dateString = "3/4/21"; + String format = "M/d/yyyy"; + + assertThatThrownBy(() -> Functions.stringToDate(dateString, format)) + .isInstanceOf(InterpretException.class); + } + + @Test + public void itFailsOnNullDatetimeFormat() { + String dateString = "3/4/21"; + String format = null; + + assertThatThrownBy(() -> Functions.stringToDate(dateString, format)) + .isInstanceOf(InterpretException.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/StringToTimeFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/StringToTimeFunctionTest.java new file mode 100644 index 000000000..04e8550f4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/StringToTimeFunctionTest.java @@ -0,0 +1,57 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Test; + +public class StringToTimeFunctionTest { + + @Test + public void itConvertsStringToTime() { + PyishDate expected = new PyishDate( + ZonedDateTime.of(2018, 7, 14, 14, 31, 30, 0, ZoneOffset.ofHoursMinutes(5, 30)) + ); + assertThat( + Functions.stringToTime("2018-07-14T14:31:30+0530", "yyyy-MM-dd'T'HH:mm:ssZ") + ) + .isEqualTo(expected); + } + + @Test + public void itFailsOnInvalidFormat() { + assertThatExceptionOfType(InterpretException.class) + .isThrownBy(() -> + Functions.stringToTime("2018-07-14T14:31:30+0530", "not a time format") + ) + .withMessageContaining("requires valid datetime format"); + } + + @Test + public void itFailsOnTimeFormatMismatch() { + assertThatExceptionOfType(InterpretException.class) + .isThrownBy(() -> + Functions.stringToTime( + "Saturday, Jul 14, 2018 14:31:06 PM", + "yyyy-MM-dd'T'HH:mm:ssZ" + ) + ) + .withMessageContaining("could not match datetime input"); + } + + @Test + public void itReturnsNullOnNullInput() { + assertThat(Functions.stringToTime(null, "yyyy-MM-dd'T'HH:mm:ssZ")).isEqualTo(null); + } + + @Test + public void itFailsOnNullDatetimeFormat() { + assertThatExceptionOfType(InterpretException.class) + .isThrownBy(() -> Functions.stringToTime("2018-07-14T14:31:30+0530", null)) + .withMessageContaining("requires non-null datetime format"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/TodayFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/TodayFunctionTest.java new file mode 100644 index 000000000..3a380e471 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/TodayFunctionTest.java @@ -0,0 +1,89 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.AutoCloseableSupplier.AutoCloseableImpl; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.date.FixedDateTimeProvider; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.Test; + +public class TodayFunctionTest extends BaseInterpretingTest { + + private static final String ZONE_NAME = "America/New_York"; + private static final ZoneId ZONE_ID = ZoneId.of(ZONE_NAME); + + @Test + public void itDefaultsToUtcTimezone() { + ZonedDateTime zonedDateTime = Functions.today(); + assertThat(zonedDateTime.getZone()).isEqualTo(ZoneOffset.UTC); + } + + @Test + public void itUsesFixedDateTimeProvider() { + long ts = 1233333414223L; + + try ( + AutoCloseableImpl a = JinjavaInterpreter + .closeablePushCurrent( + new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(new FixedDateTimeProvider(ts)) + .build() + ) + ) + .get() + ) { + assertThat(Functions.today(ZONE_NAME)) + .isEqualTo(ZonedDateTime.of(2009, 1, 30, 0, 0, 0, 0, ZONE_ID)); + } + } + + @Test + public void itParsesTimezones() { + ZonedDateTime zonedDateTime = Functions.today(ZONE_NAME); + assertThat(zonedDateTime.getZone()).isEqualTo(ZONE_ID); + } + + @Test(expected = InvalidArgumentException.class) + public void itThrowsExceptionOnInvalidTimezone() { + Functions.today("Not a timezone"); + } + + @Test + public void itIgnoresNullTimezone() { + assertThat(Functions.today((String) null).getZone()).isEqualTo(ZoneOffset.UTC); + } + + @Test + public void itDefersWhenExecutingEagerly() { + try ( + AutoCloseableImpl a = JinjavaInterpreter + .closeablePushCurrent( + new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ) + ) + .get() + ) { + ZonedDateTime today = Functions.today(ZONE_NAME); + assertThat(today.getYear()).isGreaterThan(2023); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/TypeFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/TypeFunctionTest.java new file mode 100644 index 000000000..58aca72f9 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/TypeFunctionTest.java @@ -0,0 +1,65 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.objects.SafeString; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import org.junit.Test; + +public class TypeFunctionTest { + + @Test + public void testString() { + assertThat(TypeFunction.type(" foo ")).isEqualTo("str"); + } + + @Test + public void testInteger() { + assertThat(TypeFunction.type(123)).isEqualTo("int"); + } + + @Test + public void testLong() { + assertThat(TypeFunction.type(123L)).isEqualTo("long"); + } + + @Test + public void testDouble() { + assertThat(TypeFunction.type(123.3345d)).isEqualTo("float"); + } + + @Test + public void testDate() { + assertThat( + TypeFunction.type(ZonedDateTime.parse("2013-11-06T14:22:00.000+00:00[UTC]")) + ) + .isEqualTo("datetime"); + } + + @Test + public void testList() { + assertThat(TypeFunction.type(new ArrayList<>())).isEqualTo("list"); + } + + @Test + public void testDict() { + assertThat(TypeFunction.type(new HashMap<>())).isEqualTo("dict"); + } + + @Test + public void testBool() { + assertThat(TypeFunction.type(Boolean.FALSE)).isEqualTo("bool"); + } + + @Test + public void testSafeString() { + assertThat(TypeFunction.type(new SafeString("foo"))).isEqualTo("str"); + } + + @Test + public void testNull() { + assertThat(TypeFunction.type(null)).isEqualTo("null"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/UnixTimestampFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/UnixTimestampFunctionTest.java new file mode 100644 index 000000000..380c72a50 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/UnixTimestampFunctionTest.java @@ -0,0 +1,78 @@ +package com.hubspot.jinjava.lib.fn; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.date.FixedDateTimeProvider; +import java.time.ZonedDateTime; +import org.assertj.core.data.Offset; +import org.junit.After; +import org.junit.Test; + +public class UnixTimestampFunctionTest { + + private final ZonedDateTime d = ZonedDateTime.parse( + "2013-11-06T14:22:12.345+00:00[UTC]" + ); + private final long epochMilliseconds = d.toEpochSecond() * 1000 + 345; + + @After + public void tearDown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itGetsUnixTimestamps() { + JinjavaInterpreter jinjavaInterpreter = new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest.newConfigBuilder().build() + ); + JinjavaInterpreter.pushCurrent(jinjavaInterpreter); + assertThat(Functions.unixtimestamp()) + .isGreaterThan(0) + .isLessThanOrEqualTo(System.currentTimeMillis()); + assertThat(Functions.unixtimestamp(epochMilliseconds)).isEqualTo(epochMilliseconds); + assertThat(Functions.unixtimestamp(d)).isEqualTo(epochMilliseconds); + assertThat(Functions.unixtimestamp((Object) null)) + .isCloseTo(System.currentTimeMillis(), Offset.offset(1000L)); + assertThat(jinjavaInterpreter.getErrors()).isEmpty(); + } + + @Test + public void itUsesFixedDateTimeProvider() { + long ts = 1233333414223L; + + JinjavaInterpreter.pushCurrent( + new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(new FixedDateTimeProvider(ts)) + .build() + ) + ); + assertThat(Functions.unixtimestamp((Object) null)).isEqualTo(ts); + } + + @Test(expected = DeferredValueException.class) + public void itDefersWhenExecutingEagerly() { + JinjavaInterpreter.pushCurrent( + new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ) + ); + Functions.unixtimestamp((Object) null); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunctionTest.java b/src/test/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunctionTest.java new file mode 100644 index 000000000..75d53e6d2 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/fn/eager/EagerMacroFunctionTest.java @@ -0,0 +1,163 @@ +package com.hubspot.jinjava.lib.fn.eager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerMacroFunctionTest extends BaseInterpretingTest { + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(10) + .build() + ); + context.put("deferred", DeferredValue.instance()); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itReconstructsImage() { + String name = "foo"; + String code = "{% macro foo(bar) %}It's: {{ bar }}{% endmacro %}"; + EagerMacroFunction eagerMacroFunction = makeMacroFunction(name, code); + assertThat(eagerMacroFunction.reconstructImage(name)).isEqualTo(code); + } + + @Test + public void itResolvesFromContext() { + context.put("foobar", "resolved"); + String name = "foo"; + String code = "{% macro foo(bar) %}{{ foobar }} and {{ bar }}{% endmacro %}"; + EagerMacroFunction eagerMacroFunction = makeMacroFunction(name, code); + assertThat(eagerMacroFunction.reconstructImage(name)) + .isEqualTo("{% macro foo(bar) %}resolved and {{ bar }}{% endmacro %}"); + } + + @Test + public void itReconstructsForAliasedName() { + context.remove("deferred"); + String name = "foo"; + String fullName = "local." + name; + String codeFormat = "{%% macro %s(bar) %%}It's: {{ bar }}{%% endmacro %%}"; + EagerMacroFunction eagerMacroFunction = makeMacroFunction( + name, + String.format(codeFormat, name) + ); + assertThat(eagerMacroFunction.reconstructImage(fullName)) + .isEqualTo(String.format(codeFormat, fullName)); + } + + @Test + public void itResolvesFromSet() { + String template = + "{% macro foo(foobar, other) %}" + + " {% do foobar.update({'a': 'b'}) %} " + + " {{ foobar }} and {{ other }}" + + "{% endmacro %}" + + "{% set bar = {} %}" + + "{% call foo(bar, deferred) %} {% endcall %}" + + "{{ bar }}"; + String firstPass = interpreter.render(template); + assertThat(firstPass).isEqualTo(template); + } + + @Test + public void itReconstructsImageWithNamedParams() { + String name = "foo"; + String code = "{% macro foo(bar, baz=0) %}It's: {{ bar }}, {{ baz }}{% endmacro %}"; + EagerMacroFunction eagerMacroFunction = makeMacroFunction(name, code); + assertThat(eagerMacroFunction.reconstructImage(name)).isEqualTo(code); + } + + @Test + public void itPartiallyEvaluatesMacroFunction() { + // Put this test here because it's only used in eager execution + context.put("deferred", DeferredValue.instance()); + EagerMacroFunction eagerMacroFunction = makeMacroFunction( + "foo", + "{% macro foo(bar) %}It's: {{ bar }}, {{ deferred }}{% endmacro %}" + ); + assertThatThrownBy(() -> eagerMacroFunction.evaluate("Bar")) + .isInstanceOf(DeferredValueException.class); + try (TemporaryValueClosable ignored = context.withPartialMacroEvaluation()) { + assertThat(eagerMacroFunction.evaluate("Bar")) + .isEqualTo("{% for __ignored__ in [0] %}It's: Bar, {{ deferred }}{% endfor %}"); + } + } + + @Test + public void itDoesNotAllowStackOverflow() { + String name = "rec"; + String code = + "{% macro rec(num=0) %}{% if num > 0 %}{{ num }}-{{ rec(num - 1)}}{% endif %}{% endmacro %}"; + EagerMacroFunction eagerMacroFunction = makeMacroFunction(name, code); + String output; + try (InterpreterScopeClosable c = interpreter.enterScope()) { + output = eagerMacroFunction.reconstructImage(); + } + assertThat(interpreter.render(output + "{{ rec(5) }}")).isEqualTo("5-4-3-2-1-"); + } + + @Test + public void itDefersDifferentMacrosWithSameName() { + // This kind of situation can happen when importing and the imported macro function calls another that has a name clash + String foo1Code = "{% macro foo(var) %}This is the {{ var }}{% endmacro %}"; + String barCode = "{% macro bar(var) %}^{{ foo(var) }}^{% endmacro %}"; + String foo2Code = "{% macro foo(var) %}~{{ bar(var) }}~{% endmacro %}"; + MacroFunction foo1Macro; + MacroFunction barMacro; + try (InterpreterScopeClosable c = interpreter.enterScope()) { // Imitate importing + interpreter.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, "some_path"); + foo1Macro = makeMacroFunction("foo", foo1Code); + foo1Macro.setDeferred(true); + barMacro = makeMacroFunction("bar", barCode); + barMacro.setDeferred(true); + } + interpreter.getContext().addGlobalMacro(foo1Macro); + interpreter.getContext().addGlobalMacro(barMacro); + + EagerMacroFunction foo2Macro; + String output; + try (InterpreterScopeClosable c = interpreter.enterScope()) { + foo2Macro = makeMacroFunction("foo", foo2Code); + output = foo2Macro.reconstructImage(); + } + assertThat(interpreter.render(output + "{{ foo('Foo') }}")) + .isEqualTo("~^This is the Foo^~"); + } + + private EagerMacroFunction makeMacroFunction(String name, String code) { + interpreter.render(code); + return (EagerMacroFunction) interpreter.getContext().getGlobalMacro(name); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/AutoEscapeTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/AutoEscapeTagTest.java index 4d7dd0bb6..f1279a411 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/AutoEscapeTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/AutoEscapeTagTest.java @@ -2,38 +2,27 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; - -import org.junit.Before; import org.junit.Test; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; - -public class AutoEscapeTagTest { - - private Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class AutoEscapeTagTest extends BaseJinjavaTest { @Test public void itEscapesVarsInScope() throws IOException { Map context = new HashMap<>(); context.put("myvar", "foo < bar"); - String template = Resources.toString(Resources.getResource("tags/autoescapetag/autoescape.jinja"), StandardCharsets.UTF_8); + String template = Resources.toString( + Resources.getResource("tags/autoescapetag/autoescape.jinja"), + StandardCharsets.UTF_8 + ); String result = jinjava.render(template, context); - assertThat(result).contains( - "1. foo < bar", - "2. foo < bar", - "3. foo < bar"); + assertThat(result).contains("1. foo < bar", "2. foo < bar", "3. foo < bar"); } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java new file mode 100644 index 000000000..1d9312f7e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import org.junit.Test; + +public class BreakTagTest extends BaseInterpretingTest { + + @Test + public void testBreak() { + String template = + "{% for item in [1, 2, 3, 4] %}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("12"); + } + + @Test + public void testNestedBreak() { + String template = + "{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% break %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("1234"); + } + + @Test + public void testBreakWithEarlierContent() { + String template = + "{% for item in [1, 2, 3, 4] %}{{ item }}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("11223"); + } + + @Test + public void testBreakOutOfContext() { + String template = "{% break %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo(""); + assertThat(rendered.getErrors()).hasSize(1); + assertThat(rendered.getErrors().get(0).getSeverity()) + .isEqualTo(TemplateError.ErrorType.FATAL); + assertThat(rendered.getErrors().get(0).getMessage()) + .contains("NotInLoopException: `break` called while not in a for loop"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/CallTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/CallTagTest.java index 883b8fd85..b3b152d02 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/CallTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/CallTagTest.java @@ -2,48 +2,57 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.IOException; import java.nio.charset.StandardCharsets; - import org.jsoup.Jsoup; import org.jsoup.nodes.Document; -import org.junit.After; -import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - -public class CallTagTest { - - JinjavaInterpreter interpreter; - - @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); - JinjavaInterpreter.pushCurrent(interpreter); - } - - @After - public void cleanup() { - JinjavaInterpreter.popCurrent(); - } +public class CallTagTest extends BaseInterpretingTest { @Test public void testSimpleFn() { Document dom = Jsoup.parseBodyFragment(interpreter.render(fixture("simple"))); assertThat(dom.select("div h2").text().trim()).isEqualTo("Hello World"); - assertThat(dom.select("div.contents").text().trim()).isEqualTo("This is a simple dialog rendered by using a macro and a call block."); + assertThat(dom.select("div.contents").text().trim()) + .isEqualTo("This is a simple dialog rendered by using a macro and a call block."); + } + + @Test + public void itDoesNotDoubleCountCallTagTowardsDepth() throws IOException { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(6) // There are 3 call tags, but a total of 6 "macro" calls happening in this file as each call to `caller()` counts too + .build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + + try { + String template = fixture("multiple"); + interpreter.render(template); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } finally { + JinjavaInterpreter.popCurrent(); + } } private String fixture(String name) { try { - return Resources.toString(Resources.getResource(String.format("tags/calltag/%s.jinja", name)), StandardCharsets.UTF_8); + return Resources.toString( + Resources.getResource(String.format("tags/calltag/%s.jinja", name)), + StandardCharsets.UTF_8 + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java new file mode 100644 index 000000000..3b0ac69bc --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import org.junit.Test; + +public class ContinueTagTest extends BaseInterpretingTest { + + @Test + public void testContinue() { + String template = + "{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("13"); + } + + @Test + public void testNestedContinue() { + String template = + "{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% continue %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("1234"); + } + + @Test + public void testContinueWithEarlierContent() { + String template = + "{% for item in [1, 2, 3, 4] %}{{ item }}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("112334"); + } + + @Test + public void testContinueOutOfContext() { + String template = "{% continue %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo(""); + assertThat(rendered.getErrors()).hasSize(1); + assertThat(rendered.getErrors().get(0).getSeverity()) + .isEqualTo(TemplateError.ErrorType.FATAL); + assertThat(rendered.getErrors().get(0).getMessage()) + .contains("NotInLoopException: `continue` called while not in a for loop"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/CycleTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/CycleTagTest.java new file mode 100644 index 000000000..28b062077 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/CycleTagTest.java @@ -0,0 +1,42 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import org.junit.Test; + +public class CycleTagTest extends BaseInterpretingTest { + + @Test + public void itDefaultsNullToImage() { + String template = "{% for item in [0,1] %}{% cycle {{item}} %}{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo("{{item}}{{item}}"); + } + + @Test + public void itDefaultsMultipleNullToImage() { + String template = "{% for item in [0,1] %}{% cycle {{foo}},{{bar}} %}{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo("{{foo}}{{bar}}"); + } + + @Test + public void itDefaultsNullToImageUsingAs() { + String template = + "{% for item in [0,1] %}{% cycle {{item}} as var %}{% cycle var %}{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo("{{item}}{{item}}"); + } + + @Test + public void itDefaultsMultipleNullToImageUsingAs() { + String template = + "{% for item in [0,1] %}{% cycle {{foo}},{{bar}} as var %}{% cycle var %}{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo("{{foo}}{{bar}}"); + } + + @Test + public void itHandlesEscapedQuotes() { + String template = + "{% for item in [0,1] %}{% cycle 'a','class=\\'foo bar\\'' %}.{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo("a.class='foo bar'."); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/DoTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/DoTagTest.java new file mode 100644 index 000000000..a039e983d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/DoTagTest.java @@ -0,0 +1,34 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.Maps; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import org.junit.Test; + +public class DoTagTest extends BaseInterpretingTest { + + @Test + public void itResolvesExpressions() { + String template = "{% set output = [] %}{% do output.append('hey') %}{{ output }}"; + assertThat(jinjava.render(template, Maps.newHashMap())).isEqualTo("['hey']"); + } + + @Test + public void itAddsTemplateErrorOnEmptyExpressionAndNoEndTag() { + String template = "{% do %}"; + RenderResult renderResult = jinjava.renderForResult(template, Maps.newHashMap()); + assertThat(renderResult.getErrors()).hasSize(1); + assertThat(renderResult.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.SYNTAX_ERROR); + } + + @Test + public void itEvaluatesDoBlockAndDiscardsResult() { + String template = + "{% do %}{% set foo = 1 %}{{ foo }}{% enddo %}{% if foo == 1 %}Yes{% endif %}"; + assertThat(jinjava.render(template, Maps.newHashMap())).isEqualTo("Yes"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ExtendsTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ExtendsTagTest.java index c52b75516..b5c091151 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/ExtendsTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ExtendsTagTest.java @@ -1,56 +1,69 @@ package com.hubspot.jinjava.lib.tag; +import static com.hubspot.jinjava.loader.RelativePathResolver.CURRENT_PATH_CONTEXT_KEY; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import com.google.common.collect.SetMultimap; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TagCycleException; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.HashMap; - +import java.util.Optional; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.junit.Before; import org.junit.Test; -import com.google.common.collect.SetMultimap; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.JinjavaConfig; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.interpret.RenderResult; -import com.hubspot.jinjava.loader.ResourceLocator; - -public class ExtendsTagTest { +public class ExtendsTagTest extends BaseInterpretingTest { private ExtendsTagTestResourceLocator locator; - private Jinjava jinjava; @Before public void setup() { locator = new ExtendsTagTestResourceLocator(); - JinjavaConfig conf = new JinjavaConfig(); - jinjava = new Jinjava(conf); jinjava.setResourceLocator(locator); } @Test public void singleExtendsTag() throws Exception { - String result = jinjava.render(locator.fixture("extends-base1.jinja"), new HashMap()); + String result = jinjava.render( + locator.fixture("extends-base1.jinja"), + new HashMap() + ); Document dom = Jsoup.parse(result); - assertThat(dom.select(".important").get(0).text()).isEqualTo("Welcome on my awesome homepage."); + assertThat(dom.select(".important").get(0).text()) + .isEqualTo("Welcome on my awesome homepage."); } @Test public void nestedExtendsHierarchy() throws Exception { - String result = jinjava.render(locator.fixture("extends-base2.jinja"), new HashMap()); + String result = jinjava.render( + locator.fixture("extends-base2.jinja"), + new HashMap() + ); Document dom = Jsoup.parse(result); assertThat(dom.select(".important").get(0).text()).isEqualTo("foobar"); } @Test public void parentTemplateImportsVarsUsedInChild() throws Exception { - Document dom = Jsoup.parse(jinjava.render(locator.fixture("parentvars-child.html"), new HashMap())); + Document dom = Jsoup.parse( + jinjava.render( + locator.fixture("parentvars-child.html"), + new HashMap() + ) + ); assertThat(dom.select("body").attr("bgcolor")).isEqualTo("#f5f5f5"); assertThat(dom.select("p.foo").text()).isEqualTo("bar"); @@ -58,7 +71,9 @@ public void parentTemplateImportsVarsUsedInChild() throws Exception { @Test public void extendsWithSuperCall() throws Exception { - Document dom = Jsoup.parse(jinjava.render(locator.fixture("super-child.html"), new HashMap())); + Document dom = Jsoup.parse( + jinjava.render(locator.fixture("super-child.html"), new HashMap()) + ); assertThat(dom.select(".sidebar p").text()).isEqualTo("this is a sidebar."); assertThat(dom.select(".sidebar h3").text()).isEqualTo("Table Of Contents"); @@ -66,8 +81,13 @@ public void extendsWithSuperCall() throws Exception { @Test public void itHasExtendsReferenceInContext() throws Exception { - RenderResult renderResult = jinjava.renderForResult(locator.fixture("super-child.html"), new HashMap()); - SetMultimap dependencies = renderResult.getContext().getDependencies(); + RenderResult renderResult = jinjava.renderForResult( + locator.fixture("super-child.html"), + new HashMap() + ); + SetMultimap dependencies = renderResult + .getContext() + .getDependencies(); assertThat(dependencies.size()).isEqualTo(1); assertThat(dependencies.get("coded_files")).isNotEmpty(); @@ -75,14 +95,192 @@ public void itHasExtendsReferenceInContext() throws Exception { assertThat(dependencies.get("coded_files").contains("super-base.html")); } + @Test + public void itAvoidsSimpleExtendsCycles() throws IOException { + try { + jinjava.render( + locator.fixture("extends-self.jinja"), + new HashMap() + ); + fail("expected extends tag cycle exception"); + } catch (FatalTemplateErrorsException e) { + TagCycleException cycleException = (TagCycleException) e + .getErrors() + .iterator() + .next() + .getException(); + assertThat(cycleException.getPath()).isEqualTo("extends-self.jinja"); + } + } + + @Test + public void itAvoidsNestedExtendsCycles() throws IOException { + try { + jinjava.render(locator.fixture("a-extends-b.jinja"), new HashMap()); + fail("expected extends tag cycle exception"); + } catch (FatalTemplateErrorsException e) { + TagCycleException cycleException = (TagCycleException) e + .getErrors() + .iterator() + .next() + .getException(); + assertThat(cycleException.getPath()).isEqualTo("b-extends-a.jinja"); + } + } + + @Test + public void itExtendsViaRelativePath() throws IOException { + jinjava + .getGlobalContext() + .put(CURRENT_PATH_CONTEXT_KEY, "relative/relative-extends.jinja"); + String result = jinjava.render( + locator.fixture("relative/relative-extends.jinja"), + new HashMap<>() + ); + assertThat(result).contains("This is a relative path extends"); + } + + @Test + public void itHandlesRelativePathsInBlocksInExtendingTemplate() throws IOException { + jinjava + .getGlobalContext() + .put(CURRENT_PATH_CONTEXT_KEY, "relative/relative-block.jinja"); + String result = jinjava.render( + locator.fixture("relative/relative-block.jinja"), + new HashMap<>() + ); + assertThat(result).contains("hello"); + } + + @Test + public void itHandlesRelativePathsInBlocksFromExtendedTemplate() throws IOException { + jinjava + .getGlobalContext() + .put(CURRENT_PATH_CONTEXT_KEY, "relative/relative-block-2.jinja"); + String result = jinjava.render( + locator.fixture("relative/relative-block-2.jinja"), + new HashMap<>() + ); + assertThat(result).contains("hello"); + } + + @Test + public void itResolvesRelativePathsWithMultipleLevelsOfInheritance() + throws IOException { + jinjava + .getGlobalContext() + .put(CURRENT_PATH_CONTEXT_KEY, "relative/nested-relative-extends.jinja"); + String result = jinjava.render( + locator.fixture("relative/nested-relative-extends.jinja"), + new HashMap<>() + ); + assertThat(result).contains("hello"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksInExtendingTemplate() + throws IOException { + RenderResult result = jinjava.renderForResult( + locator.fixture("errors/extends-no-error.jinja"), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(8); + assertThat(result.getErrors().get(0).getMessage()) + .doesNotContain("Error in `/errors/no-error.html` on line"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplate() + throws IOException { + RenderResult result = jinjava.renderForResult( + locator.fixture("errors/extends-block-error.jinja"), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(5); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `/errors/block-error.html` on line 16"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("/errors/block-error.html"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyOutsideBlocksFromExtendedTemplate() + throws IOException { + RenderResult result = jinjava.renderForResult( + locator.fixture("errors/extends-outside-block-error.jinja"), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `/errors/outside-block-error.html` on line 9"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("/errors/outside-block-error.html"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplateInIncludedTemplate() + throws IOException { + RenderResult result = jinjava.renderForResult( + locator.fixture("errors/extends-include-block-error.jinja"), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(3); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `/errors/error.html` on line 5"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("/errors/error.html"); + } + + @Test + public void itLimitsExtendsWithMultipleLevels() throws IOException { + // Previously this would run infinitely + RenderResult result = jinjava.renderForResult( + locator.fixture("rec1.jinja"), + new HashMap<>() + ); + assertThat(result.getOutput().trim()).isEqualTo("foo"); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()) + .contains("ExtendsTagCycleException"); + } + private static class ExtendsTagTestResourceLocator implements ResourceLocator { + + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + @Override - public String getString(String fullName, Charset encoding, JinjavaInterpreter interpreter) throws IOException { + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { return fixture(fullName); } public String fixture(String name) throws IOException { - return Resources.toString(Resources.getResource(String.format("tags/extendstag/%s", name)), StandardCharsets.UTF_8); + return Resources.toString( + Resources.getResource(String.format("tags/extendstag/%s", name)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); } } } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java index 9370101bc..1895a25a4 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java @@ -1,47 +1,71 @@ package com.hubspot.jinjava.lib.tag; import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.select.Elements; -import org.junit.Before; -import org.junit.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertEquals; import com.google.common.base.Splitter; -import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.objects.date.PyishDate; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.tree.TreeParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; +import org.junit.Before; +import org.junit.Test; -public class ForTagTest { - - ForTag tag; +public class ForTagTest extends BaseInterpretingTest { - Context context; - JinjavaInterpreter interpreter; + public Tag tag; @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); - context = interpreter.getContext(); + @Override + public void baseSetup() { + super.baseSetup(); + jinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withKeepTrailingNewline(true).build() + ); tag = new ForTag(); + + try { + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "in_for_loop", + this.getClass().getDeclaredMethod("inForLoop") + ) + ); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } } @Test - public void forLoopUsingLoopLastVar() throws Exception { + public void forLoopUsingLoopLastVar() { context.put("the_list", Lists.newArrayList(1L, 2L, 3L, 7L)); TagNode tagNode = (TagNode) fixture("loop-last-var"); Document dom = Jsoup.parseBodyFragment(tag.interpret(tagNode, interpreter)); @@ -50,15 +74,28 @@ public void forLoopUsingLoopLastVar() throws Exception { } @Test - public void forLoopNestedFor() throws Exception { + public void forLoopUsingScalarValue() { + context.put("the_list", 999L); + TagNode tagNode = (TagNode) fixture("loop-with-scalar"); + String output = tag.interpret(tagNode, interpreter); + assertThat(output.trim()).isEqualTo("

The Number: 999

"); + } + + @Test + public void forLoopNestedFor() { TagNode tagNode = (TagNode) fixture("nested-fors"); - assertThat(Splitter.on("\n").trimResults().omitEmptyStrings().split( - tag.interpret(tagNode, interpreter))) - .contains("02", "03", "12", "13"); + assertThat( + Splitter + .on("\n") + .trimResults() + .omitEmptyStrings() + .split(tag.interpret(tagNode, interpreter)) + ) + .contains("02", "03", "12", "13"); } @Test - public void forLoopMultipleLoopVars() throws Exception { + public void forLoopMultipleLoopVars() { Map dict = Maps.newHashMap(); dict.put("foo", "one"); dict.put("bar", 2L); @@ -71,29 +108,50 @@ public void forLoopMultipleLoopVars() throws Exception { } @Test - public void forLoopLiteralLoopExpr() throws Exception { + public void forLoopMultipleLoopVarsArbitraryNames() { + Map dict = ImmutableMap.of("grand", "ol'", "adserving", "team"); + + context.put("the_dictionary", dict); + String template = + "" + + "{% for foo, bar in the_dictionary.items() %}" + + "{{ foo }}: {{ bar }}\n" + + "{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertEquals("grand: ol'\nadserving: team\n", rendered); + } + + @Test + public void forLoopLiteralLoopExpr() { TagNode tagNode = (TagNode) fixture("literal-loop-expr"); assertThat(tag.interpret(tagNode, interpreter)).isEqualTo("012345"); } @Test - public void forLoopWithNestedCycle() throws Exception { + public void forLoopWithNestedCycle() { context.put("cycle1", "odd"); context.put("cycle2", "even"); TagNode tagNode = (TagNode) fixture("nested-cycle"); - List result = Lists.newArrayList(Splitter.on("\n").omitEmptyStrings().trimResults().split(tag.interpret(tagNode, interpreter))); + List result = Lists.newArrayList( + Splitter + .on("\n") + .omitEmptyStrings() + .trimResults() + .split(tag.interpret(tagNode, interpreter)) + ); assertThat(result).containsExactly("odd", "even", "odd", "even", "odd"); } @Test - public void forLoopIndexVar() throws Exception { + public void forLoopIndexVar() { TagNode tagNode = (TagNode) fixture("loop-index-var"); assertThat(tag.interpret(tagNode, interpreter)).isEqualTo("012345"); } @Test - public void forLoopSupportsAllLoopVarsInHublDocs() throws Exception { + public void forLoopSupportsAllLoopVarsInHublDocs() { TagNode tagNode = (TagNode) fixture("hubl-docs-loop-vars"); Document dom = Jsoup.parseBodyFragment(tag.interpret(tagNode, interpreter)); @@ -110,18 +168,262 @@ public void forLoopSupportsAllLoopVarsInHublDocs() throws Exception { assertThat(dom.select(".item-0 .depth").text()).isEqualTo("1"); assertThat(dom.select(".item-0 .depth0").text()).isEqualTo("0"); - // TODO for loop depth not implemented - // assertThat(dom.select(".item-0 .subnum").text()).isEqualTo("6 1"); + assertThat(dom.select(".item-0 .subnum").text()).isEqualTo("6 0"); + } + + @Test + public void testForLoopConstants() { + Map context = Maps.newHashMap(); + String template = "" + "{% for i in range(1 * 1, 2 * 2) %}{{i}}{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertEquals("123", rendered); + } + + @Test + public void testForLoopVariablesWithoutSpaces() { + Map context = Maps.newHashMap(); + context.put("a", 2); + context.put("b", 3); + + String template = + "" + "{% for index in range(a*b,a*b+b) %}" + "{{index}} " + "{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertEquals("6 7 8 ", rendered); + } + + @Test + public void testForLoopVariablesWithSpaces() { + Map context = Maps.newHashMap(); + context.put("a", 2); + context.put("b", 3); + + String template = + "" + "{% for index in range(a * b, a * b + b) %}" + "{{index}} " + "{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertEquals("6 7 8 ", rendered); + } + + @Test + public void testForLoopRangeWithStringsWithSpaces() { + Map context = Maps.newHashMap(); + String template = "" + "{% for i in ['a ','b'] %}{{i}}{% endfor %}"; + String rendered = jinjava.render(template, context); + assertEquals("a b", rendered); + } + + @Test + public void testForLoopWithDates() { + Date testDate = new Date(); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(false).build() + ) + .build() + ); + interpreter.getContext().put("the_list", Lists.newArrayList(testDate)); + String template = "" + "{% for i in the_list %}{{i}}{% endfor %}"; + try { + JinjavaInterpreter.pushCurrent(interpreter); + String rendered = interpreter.render(template); + assertEquals(new PyishDate(testDate).toString(), rendered); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void testTuplesWithPyList() { + String template = + "{% for href, caption in [('index.html', 'Index'), ('downloads.html', 'Downloads'), ('products.html', 'Products')] %}" + + "
  • {{caption}}
  • \n" + + "{% endfor %}"; + String expected = + "
  • Index
  • \n" + + "
  • Downloads
  • \n" + + "
  • Products
  • \n"; + + String rendered = jinjava.render(template, context); + assertEquals(rendered, expected); + } + + @Test + public void testTuplesWithThreeValues() { + String template = + "{% for a, b, c in [(1,2,3), (4,5,6)] %}" + + "

    {{a}} {{b}} {{c}}

    \n" + + "{% endfor %}"; + String expected = "

    1 2 3

    \n" + "

    4 5 6

    \n"; + String rendered = jinjava.render(template, context); + assertEquals(rendered, expected); + } + + @Test + public void testWithSingleTuple() { + String template = + "{% for a, b, c, d in [(43, 21, 33, 54)] %}" + + "

    {{a}} - {{b}}, {{c}} - {{d}}

    " + + "{% endfor %}"; + String expected = "

    43 - 21, 33 - 54

    "; + String rendered = jinjava.render(template, context); + assertEquals(rendered, expected); + } + + @Test + public void testTuplesWithNonStringValues() { + String template = + "{% for firstVal, secondVal in [(32, 21)] %}" + + "{{firstVal + secondVal}}" + + "{% endfor %}"; + String rendered = jinjava.render(template, context); + assertEquals(rendered, "53"); + } + + @Test + public void testRenderingFailsForLessValues() { + String template = "{% for a,b,c in [(1,2)] %}" + "{{a}} {{b}} {{c}}" + "{% endfor %}"; + assertThatThrownBy(() -> jinjava.render(template, context)) + .isInstanceOf(InterpretException.class) + .hasMessageContaining("Error rendering tag"); + } + + @Test + public void testForLoopWithBooleanFromNamespaceVariable() { + String template = + "{% set ns = namespace(found=false) %}" + + "{% for item in items %}" + + "{% if item=='B' %}" + + "{% set ns.found=true %}" + + "{% endif %}" + + "{% endfor %}" + + "Found item having something: {{ ns.found }}"; + + context.put("items", Lists.newArrayList("A", "B")); + String rendered = jinjava.render(template, context); + assertThat(rendered).isEqualTo("Found item having something: true"); + } + + @Test + public void forLoopShouldCountUsingNamespaceVariable() { + String template = + "{% set ns = namespace(found=2) %}" + + "{% for item in items %}" + + "{% set ns.found= ns.found + 1 %}" + + "{% endfor %}" + + "Found item having something: {{ ns.found }}"; + + context.put("items", Lists.newArrayList("A", "B")); + String rendered = jinjava.render(template, context); + assertThat(rendered).isEqualTo("Found item having something: 4"); + } + + @Test + public void itShouldHandleSpacesInMaps() { + String template = + "{% for item in [{'key': 'foo?'}, {'key': 'bar?'}] %}" + + "{{ item.key }}\n" + + "{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertThat(rendered).isEqualTo("foo?\nbar?\n"); + } + + @Test + public void itHandlesUnconventionalSpacing() { + String template = "{% for item\nin \t[0,1] %}" + "{{ item }}\n" + "{% endfor %}"; + + String rendered = jinjava.render(template, context); + assertThat(rendered).isEqualTo("0\n1\n"); } private Node fixture(String name) { try { - return new TreeParser(interpreter, Resources.toString( - Resources.getResource(String.format("tags/fortag/%s.jinja", name)), StandardCharsets.UTF_8)) - .buildTree().getChildren().getFirst(); + return new TreeParser( + interpreter, + Resources.toString( + Resources.getResource(String.format("tags/fortag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ) + .buildTree() + .getChildren() + .getFirst(); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } + @Test + public void itCatchesConcurrentModificationInLoop() { + Map context = Maps.newHashMap(); + String template = + "{% set test = [1, 2, 3] %}{% for i in test %}{{ 'hello' }}{% if i == 1 %}{{ test.append(4) }}{% endif %}{% endfor %}{{ test }}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertEquals("hellotrue[1, 2, 3, 4]", rendered.getOutput()); + assertThat(rendered.getErrors()).hasSize(1); + assertThat(rendered.getErrors().get(0).getSeverity()) + .isEqualTo(TemplateError.ErrorType.FATAL); + assertThat(rendered.getErrors().get(0).getMessage()) + .contains("Cannot modify collection in 'for' loop"); + } + + @Test + public void itAllowsCheckingOfWithinForLoop() throws NoSuchMethodException { + Map context = Maps.newHashMap(); + String template = + "{% set test = [1, 2] %}{{ in_for_loop() }} {% for i in test %}{{ in_for_loop() }} {% endfor %}{{ in_for_loop() }}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("false true true false"); + } + + @Test + public void forLoopWithNullValues() { + context.put("number", -1); + context.put("the_list", Lists.newArrayList(1L, 2L, null, null, null)); + String template = "{% for number in the_list %} {{ number }} {% endfor %}"; + TagNode tagNode = (TagNode) new TreeParser(interpreter, template) + .buildTree() + .getChildren() + .getFirst(); + String result = tag.interpret(tagNode, interpreter); + assertThat(result).isEqualTo(" 1 2 null null null "); + } + + @Test + public void forLoopTupleWithNullValues() { + context.put("number", -1); + context.put("the_list", Lists.newArrayList(1L, 2L, null, null, null)); + String template = "{% for number,name in the_list %} {{ number }} {% endfor %}"; + TagNode tagNode = (TagNode) new TreeParser(interpreter, template) + .buildTree() + .getChildren() + .getFirst(); + String result = tag.interpret(tagNode, interpreter); + // This is quite intuitive, if the value cannot be assigned to the loop var, + // the outer value of number is used as in the loop, number is not assigned if val is not null. + assertThat(result).isEqualTo(" -1 -1 null null null "); + } + + @Test + public void itUsesJinjavaRestrictedResolverOnReadingLoopVars() { + String template = + """ + {% for _, config, class in ____int3rpr3t3r____ %}{{ class }}{% endfor %}"""; + String result = interpreter.render(template); + assertThat(result).isEqualTo(""); + } + + public static boolean inForLoop() { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + return interpreter.getContext().isInForLoop(); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/FromTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/FromTagTest.java index f5b92b8d1..36dd47886 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/FromTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/FromTagTest.java @@ -1,66 +1,129 @@ package com.hubspot.jinjava.lib.tag; +import static com.hubspot.jinjava.loader.RelativePathResolver.CURRENT_PATH_CONTEXT_KEY; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.errorcategory.BasicTemplateErrorCategory; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; - -import org.junit.After; +import java.util.Optional; import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.loader.ResourceLocator; - -public class FromTagTest { - - private Context context; - private JinjavaInterpreter interpreter; +public class FromTagTest extends BaseInterpretingTest { @Before public void setup() { - Jinjava jinjava = new Jinjava(); - jinjava.setResourceLocator(new ResourceLocator() { - @Override - public String getString(String fullName, Charset encoding, - JinjavaInterpreter interpreter) throws IOException { - return Resources.toString( - Resources.getResource(String.format("tags/macrotag/%s", fullName)), StandardCharsets.UTF_8); - } - }); + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); - interpreter = jinjava.newInterpreter(); - JinjavaInterpreter.pushCurrent(interpreter); + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ); + } - context = interpreter.getContext(); + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); context.put("padding", 42); } - @After - public void cleanup() { - JinjavaInterpreter.popCurrent(); - } - @Test public void importedContextExposesVars() { assertThat(fixture("from")) - .contains("wrap-spacer:") - .contains("") - .contains("wrap-padding: padding-left:42px;padding-right:42px"); + .contains("wrap-spacer:") + .contains("") + .contains("wrap-padding: padding-left:42px;padding-right:42px"); + } + + @Test + public void itImportsAliasedMacroName() { + assertThat(fixture("from-alias-macro")) + .contains("wrap-spacer:") + .contains("") + .contains("wrap-padding: padding-left:42px;padding-right:42px"); + } + + @Test + public void importedCycleDected() { + fixture("from-recursion"); + assertTrue( + interpreter + .getErrorsCopy() + .stream() + .anyMatch(e -> e.getCategory() == BasicTemplateErrorCategory.FROM_CYCLE_DETECTED) + ); + } + + @Test + public void importedIndirectCycleDected() { + fixture("from-a-to-b"); + assertTrue( + interpreter + .getErrorsCopy() + .stream() + .anyMatch(e -> e.getCategory() == BasicTemplateErrorCategory.FROM_CYCLE_DETECTED) + ); + } + + @Test + public void itImportsWithMacroTag() { + fixture("from-simple-with-call"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itImportsViaRelativePath() { + interpreter + .getContext() + .put(CURRENT_PATH_CONTEXT_KEY, "relative/relative-from.jinja"); + fixture("relative-from"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itDefersImport() { + interpreter.getContext().put("padding", DeferredValue.instance()); + String template = fixtureText("from"); + String rendered = fixture("from"); + assertThat(rendered).isEqualTo(template); + MacroFunction spacer = interpreter.getContext().getGlobalMacro("spacer"); + assertThat(spacer.isDeferred()).isTrue(); } private String fixture(String name) { + return interpreter.renderFlat(fixtureText(name)); + } + + private String fixtureText(String name) { try { - return interpreter.renderString(Resources.toString( - Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), StandardCharsets.UTF_8)); + return Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), + StandardCharsets.UTF_8 + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/IfTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/IfTagTest.java index e664a4013..62ae0e483 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/IfTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/IfTagTest.java @@ -2,84 +2,88 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.runners.MockitoJUnitRunner; - -import com.google.common.base.Throwables; import com.google.common.collect.Lists; import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.testobjects.IfTagTestObjects; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.tree.TreeParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Before; +import org.junit.Test; -@RunWith(MockitoJUnitRunner.class) -public class IfTagTest { - - JinjavaInterpreter interpreter; - @InjectMocks - IfTag tag; +public class IfTagTest extends BaseInterpretingTest { - Jinjava jinjava; - Context context; + public Tag tag; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); - context = interpreter.getContext(); + tag = new IfTag(); } @Test - public void itEvaluatesChildrenWhenExpressionIsTrue() throws Exception { + public void itEvaluatesChildrenWhenExpressionIsTrue() { context.put("foo", "bar"); TagNode n = fixture("if"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("ifblock"); } @Test - public void itDoesntEvalChildrenWhenExprIsFalse() throws Exception { + public void itDoesntEvalChildrenWhenExprIsFalse() { context.put("foo", null); TagNode n = fixture("if"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo(""); } @Test - public void itTreatsNotFoundPropsAsNull() throws Exception { + public void itChecksObjectTruthValue() { + context.put("foo", new IfTagTestObjects.Foo().setObjectTruthValue(true)); + TagNode n = fixture("if-object"); + assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("ifblock"); + + context.put("foo", new IfTagTestObjects.Foo().setObjectTruthValue(false)); + n = fixture("if-object"); + assertThat(tag.interpret(n, interpreter).trim()).isEqualTo(""); + } + + @Test + public void itTreatsNotFoundPropsAsNull() { context.put("settings", new Object()); TagNode n = fixture("if-non-existent-prop"); assertThat(tag.interpret(n, interpreter).trim()).isEmpty(); } @Test - public void itEvalsIfNotElseWhenExpIsTrue() throws Exception { + public void itEvalsIfNotElseWhenExpIsTrue() { context.put("foo", "bar"); TagNode n = fixture("if-else"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("ifblock"); } @Test - public void itEvalsElseNotIfWhenExpIsFalse() throws Exception { + public void itEvalsElseNotIfWhenExpIsFalse() { context.put("foo", ""); TagNode n = fixture("if-else"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("elseblock"); } @Test - public void itEvalsOnlyIfInElifTreeWhenExpIsTrue() throws Exception { + public void itEvalsOnlyIfInElifTreeWhenExpIsTrue() { context.put("foo", "bar"); TagNode n = fixture("if-elif-else"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("ifblock"); } @Test - public void itEvalsOnlyElifInTreeWhenExp2IsTrue() throws Exception { + public void itEvalsOnlyIfInElifTreeWhenBothAreTrue() { + context.put("foo", "bar"); + TagNode n = fixture("if-true-elif-true"); + assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("one"); + } + + @Test + public void itEvalsOnlyElifInTreeWhenExp2IsTrue() { context.put("foo", ""); context.put("bar", "val"); TagNode n = fixture("if-elif-else"); @@ -87,7 +91,7 @@ public void itEvalsOnlyElifInTreeWhenExp2IsTrue() throws Exception { } @Test - public void itEvalsOnlyElseInTreeWhenExpsAllFalse() throws Exception { + public void itEvalsOnlyElseInTreeWhenExpsAllFalse() { context.put("foo", ""); context.put("bar", ""); TagNode n = fixture("if-elif-else"); @@ -95,7 +99,7 @@ public void itEvalsOnlyElseInTreeWhenExpsAllFalse() throws Exception { } @Test - public void itEvalsSecondElifInTreeWhenExp3IsTrue() throws Exception { + public void itEvalsSecondElifInTreeWhenExp3IsTrue() { context.put("foo", ""); context.put("bar", ""); context.put("elf", "true"); @@ -104,7 +108,7 @@ public void itEvalsSecondElifInTreeWhenExp3IsTrue() throws Exception { } @Test - public void itEvalsExprWithFilter() throws Exception { + public void itEvalsExprWithFilter() { context.put("items", Lists.newArrayList("foo", "bar")); TagNode n = fixture("if-expr-filter"); assertThat(tag.interpret(n, interpreter).trim()).isEqualTo("ifblock"); @@ -112,12 +116,18 @@ public void itEvalsExprWithFilter() throws Exception { private TagNode fixture(String name) { try { - return (TagNode) new TreeParser(interpreter, Resources.toString( - Resources.getResource(String.format("tags/iftag/%s.jinja", name)), StandardCharsets.UTF_8)) - .buildTree().getChildren().getFirst(); + return (TagNode) new TreeParser( + interpreter, + Resources.toString( + Resources.getResource(String.format("tags/iftag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ) + .buildTree() + .getChildren() + .getFirst(); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ImportTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ImportTagTest.java index 91039c0ff..afd9a7f18 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/ImportTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ImportTagTest.java @@ -1,81 +1,211 @@ package com.hubspot.jinjava.lib.tag; +import static com.hubspot.jinjava.loader.RelativePathResolver.CURRENT_PATH_CONTEXT_KEY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import com.hubspot.jinjava.testobjects.EagerImportTagTestObjects.PrintPathFilter; +import com.hubspot.jinjava.tree.Node; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; - -import org.junit.After; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.fn.MacroFunction; -import com.hubspot.jinjava.loader.ResourceLocator; - -public class ImportTagTest { - - private Context context; - private JinjavaInterpreter interpreter; +public class ImportTagTest extends BaseInterpretingTest { @Before public void setup() { - Jinjava jinjava = new Jinjava(); - jinjava.setResourceLocator(new ResourceLocator() { - @Override - public String getString(String fullName, Charset encoding, - JinjavaInterpreter interpreter) throws IOException { - return Resources.toString( - Resources.getResource(String.format("tags/macrotag/%s", fullName)), StandardCharsets.UTF_8); - } - }); - - context = new Context(); - interpreter = new JinjavaInterpreter(jinjava, context, jinjava.getGlobalConfig()); - JinjavaInterpreter.pushCurrent(interpreter); - context.put("padding", 42); - } - - @After - public void cleanup() { - JinjavaInterpreter.popCurrent(); + context.registerFilter(new PrintPathFilter()); } @Test public void itAvoidsSimpleImportCycle() throws IOException { - Jinjava jinjava = new Jinjava(); - interpreter = new JinjavaInterpreter(jinjava, context, jinjava.getGlobalConfig()); - - interpreter.render(Resources.toString(Resources.getResource("tags/importtag/imports-self.jinja"), StandardCharsets.UTF_8)); + interpreter.render( + Resources.toString( + Resources.getResource("tags/importtag/imports-self.jinja"), + StandardCharsets.UTF_8 + ) + ); assertThat(context.get("c")).isEqualTo("hello"); + + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .contains("Import cycle detected", "imports-self.jinja"); } @Test public void itAvoidsNestedImportCycle() throws IOException { - Jinjava jinjava = new Jinjava(); - interpreter = new JinjavaInterpreter(jinjava, context, jinjava.getGlobalConfig()); - - interpreter.render(Resources.toString(Resources.getResource("tags/importtag/a-imports-b.jinja"), StandardCharsets.UTF_8)); + interpreter.render( + Resources.toString( + Resources.getResource("tags/importtag/a-imports-b.jinja"), + StandardCharsets.UTF_8 + ) + ); assertThat(context.get("a")).isEqualTo("foo"); assertThat(context.get("b")).isEqualTo("bar"); + + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .contains("Import cycle detected", "b-imports-a.jinja"); + } + + @Test + public void itHandlesNullImportedValues() throws IOException { + interpreter.render( + Resources.toString( + Resources.getResource("tags/importtag/imports-null.jinja"), + StandardCharsets.UTF_8 + ) + ); + assertThat(context.get("foo")).isEqualTo("foo"); + assertThat(context.get("bar")).isEqualTo(null); } @Test public void importedContextExposesVars() { - assertThat(fixture("import")).contains("wrap-padding: padding-left:42px;padding-right:42px"); + jinjava.setResourceLocator((fullName, encoding, interpreter) -> + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ) + ); + assertThat(fixture("import")) + .contains("wrap-padding: padding-left:42px;padding-right:42px"); + } + + // Properties from within the import are deferred too. + // The main concern is that the key is deferred so that any + // subsequent uses of any variables defined in the imported template are marked as deferred + @Test + public void itDefersImportedVariableKey() { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + fixture("import-property"); + assertThat(interpreter.getContext().get("pegasus")).isInstanceOf(DeferredValue.class); + + //If pegasus was deferred at the key.prop level instead of key this would resolve to a value + assertThat(interpreter.getContext().get("expected_to_be_deferred")) + .isInstanceOf(DeferredValue.class); + DeferredValue deferredValue = (DeferredValue) interpreter.getContext().get("pegasus"); + Map originalValue = (Map) deferredValue.getOriginalValue(); + assertThat(originalValue.get("primary_line_height")).isNotNull(); + } + + @Test + public void itDefersGloballyImportedVariables() { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + fixture("import-property-global"); + assertThat(interpreter.getContext().get("primary_line_height")) + .isInstanceOf(DeferredValue.class); + } + + @Test + public void itReconstructsDeferredImportTag() { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + String renderedImport = fixture("import-property"); + assertThat(renderedImport) + .contains("{% import \"tags/settag/set-var-exp.jinja\" as pegasus %}"); + } + + @Test + public void itDoesNotRenderTagsDependingOnDeferredImport() { + try { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + String renderedImport = fixture("import-property-global"); + assertThat(renderedImport) + .isEqualTo( + Resources.toString( + Resources.getResource("tags/macrotag/import-property-global.jinja"), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(); + } + } + + @Test + public void itDoesNotRenderTagsDependingOnDeferredGlobalImport() { + try { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + String renderedImport = fixture("import-property"); + assertThat(renderedImport) + .isEqualTo( + Resources.toString( + Resources.getResource("tags/macrotag/import-property.jinja"), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(); + } + } + + @Test + public void itAddsAllDeferredNodesOfImport() { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + fixture("import-property"); + Set deferredImages = interpreter + .getContext() + .getDeferredNodes() + .stream() + .map(Node::reconstructImage) + .collect(Collectors.toSet()); + assertThat( + deferredImages + .stream() + .filter(image -> image.contains("{% set primary_line_height")) + .collect(Collectors.toSet()) + ) + .isNotEmpty(); + } + + @Test + public void itAddsAllDeferredNodesOfGlobalImport() { + interpreter.getContext().put("primary_font_size_num", DeferredValue.instance()); + fixture("import-property-global"); + Set deferredImages = interpreter + .getContext() + .getDeferredNodes() + .stream() + .map(Node::reconstructImage) + .collect(Collectors.toSet()); + assertThat( + deferredImages + .stream() + .filter(image -> image.contains("{% set primary_line_height")) + .collect(Collectors.toSet()) + ) + .isNotEmpty(); } @Test public void importedContextExposesMacros() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ) + ); assertThat(fixture("import")).contains(""); - MacroFunction fn = (MacroFunction) interpreter.resolveObject("pegasus.spacer", -1); + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "pegasus.spacer", + -1, + -1 + ); assertThat(fn.getName()).isEqualTo("spacer"); assertThat(fn.getArguments()).containsExactly("orientation", "size"); assertThat(fn.getDefaults()).contains(entry("orientation", "h"), entry("size", 42)); @@ -83,28 +213,173 @@ public void importedContextExposesMacros() { @Test public void importedContextDoesntExposePrivateMacros() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ) + ); fixture("import"); assertThat(context.get("_private")).isNull(); } @Test public void importedContextFnsProperlyResolveScopedVars() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ) + ); String result = fixture("imports-macro-referencing-macro"); - assertThat(interpreter.getErrors()).isEmpty(); + assertThat(interpreter.getErrorsCopy()).isEmpty(); assertThat(result) - .contains("using public fn: public fn: foo") - .contains("using private fn: private fn: bar") - .contains("using scoped var: myscopedvar"); + .contains("using public fn: public fn: foo") + .contains("using private fn: private fn: bar") + .contains("using scoped var: myscopedvar"); + } + + @Test + public void itImportsMacroWithCall() throws IOException { + String renderResult = interpreter.render( + Resources.toString( + Resources.getResource("tags/importtag/imports-macro.jinja"), + StandardCharsets.UTF_8 + ) + ); + assertThat(renderResult.trim()).isEqualTo(""); + assertThat(interpreter.getErrorsCopy()).hasSize(0); + } + + @Test + public void itImportsMacroViaRelativePathWithCall() throws IOException { + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + + context.put(CURRENT_PATH_CONTEXT_KEY, "tags/importtag/imports-macro-relative.jinja"); + interpreter = new JinjavaInterpreter(jinjava, context, jinjava.getGlobalConfig()); + + String renderResult = interpreter.render( + Resources.toString( + Resources.getResource("tags/importtag/imports-macro-relative.jinja"), + StandardCharsets.UTF_8 + ) + ); + assertThat(renderResult.trim()).isEqualTo(""); + assertThat(interpreter.getErrorsCopy()).hasSize(0); + } + + @Test + public void itSetsErrorLineNumbersCorrectly() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/importtag/errors/base.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(2); + + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `tags/importtag/errors/file-with-error.jinja` on line 11"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("tags/importtag/errors/file-with-error.jinja"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyThroughIncludeTag() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/importtag/errors/include.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(7); + + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `tags/importtag/errors/file-with-error.jinja` on line 11"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("tags/importtag/errors/file-with-error.jinja"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyForImportedMacros() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/importtag/errors/import-macro.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(3); + + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `tags/importtag/errors/macro-with-error.jinja` on line 12"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("tags/importtag/errors/macro-with-error.jinja"); + } + + @Test + public void itCorrectlySetsNestedPaths() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ) + ); + context.put("foo", "foo"); + assertThat( + interpreter.render( + "{% import 'double-import-macro.jinja' %}{{ print_path_macro2(foo) }}" + ) + ) + .isEqualTo("double-import-macro.jinja\n\nimport-macro.jinja\nfoo\n"); } private String fixture(String name) { try { - return interpreter.renderString(Resources.toString( - Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), StandardCharsets.UTF_8)); + return interpreter.renderFlat( + Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/IncludeTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/IncludeTagTest.java index cf1bc7367..788c186f6 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/IncludeTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/IncludeTagTest.java @@ -1,62 +1,209 @@ package com.hubspot.jinjava.lib.tag; +import static com.hubspot.jinjava.loader.RelativePathResolver.CURRENT_PATH_CONTEXT_KEY; import static org.assertj.core.api.Assertions.assertThat; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; - -import org.junit.Before; -import org.junit.Test; - import com.google.common.base.Splitter; import com.google.common.collect.SetMultimap; import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Optional; +import org.junit.Test; -public class IncludeTagTest { - - Jinjava jinjava; - - @Before - public void setup() { - jinjava = new Jinjava(); - } +public class IncludeTagTest extends BaseInterpretingTest { @Test public void itAvoidsSimpleIncludeCycles() throws IOException { - String result = jinjava.render(Resources.toString(Resources.getResource("tags/includetag/includes-self.jinja"), StandardCharsets.UTF_8), - new HashMap()); - assertThat(result).containsSequence("hello world", "hello world"); + String result = jinjava.render( + Resources.toString( + Resources.getResource("tags/includetag/includes-self.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap() + ); + assertThat(result).containsSubsequence("hello world", "hello world"); } @Test public void itAvoidsNestedIncludeCycles() throws IOException { - String result = jinjava.render(Resources.toString(Resources.getResource("tags/includetag/a-includes-b.jinja"), StandardCharsets.UTF_8), - new HashMap()); - assertThat(result).containsSequence("A", "B"); + String result = jinjava.render( + Resources.toString( + Resources.getResource("tags/includetag/a-includes-b.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap() + ); + assertThat(result).containsSubsequence("A", "B"); } @Test public void itAllowsSameIncludeMultipleTimesInATemplate() throws IOException { - String result = jinjava.render(Resources.toString(Resources.getResource("tags/includetag/c-includes-d-twice.jinja"), StandardCharsets.UTF_8), - new HashMap()); - assertThat(Splitter.on('\n').omitEmptyStrings().trimResults().split(result)).containsExactly("hello", "hello"); + String result = jinjava.render( + Resources.toString( + Resources.getResource("tags/includetag/c-includes-d-twice.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap() + ); + assertThat(Splitter.on('\n').omitEmptyStrings().trimResults().split(result)) + .containsExactly("hello", "hello"); } @Test public void itHasIncludesReferenceInContext() throws Exception { - RenderResult renderResult = jinjava.renderForResult(Resources.toString(Resources.getResource("tags/includetag/include-tag-dependencies.html"), StandardCharsets.UTF_8), - new HashMap()); + RenderResult renderResult = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/includetag/include-tag-dependencies.html"), + StandardCharsets.UTF_8 + ), + new HashMap() + ); - SetMultimap dependencies = renderResult.getContext().getDependencies(); + SetMultimap dependencies = renderResult + .getContext() + .getDependencies(); assertThat(dependencies.size()).isEqualTo(2); assertThat(dependencies.get("coded_files")).isNotEmpty(); - assertThat(dependencies.get("coded_files").contains("{% include \"tags/includetag/hello.html\" %}")); - assertThat(dependencies.get("coded_files").contains("{% include \"tags/includetag/cat.html\" %}")); + assertThat( + dependencies + .get("coded_files") + .contains("{% include \"tags/includetag/hello.html\" %}") + ); + assertThat( + dependencies + .get("coded_files") + .contains("{% include \"tags/includetag/cat.html\" %}") + ); + } + + @Test + public void itIncludesFileWithMacroCall() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/includetag/include-with-import.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itIncludesFileWithInternalMacroCall() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/macrotag/include-two-macros.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).isEmpty(); } + @Test + public void itIncludesFileViaRelativePath() throws IOException { + jinjava = new Jinjava(); + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + + jinjava + .getGlobalContext() + .put(CURRENT_PATH_CONTEXT_KEY, "tags/includetag/includes-relative-path.jinja"); + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/includetag/includes-relative-path.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getOutput().trim()).isEqualTo("INCLUDED"); + } + + @Test + public void itSetsErrorLineNumbersCorrectly() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/includetag/errors/base.html"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(7); + + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `tags/includetag/errors/error.html` on line 4"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("tags/includetag/errors/error.html"); + } + + @Test + public void itSetsErrorLineNumbersCorrectlyTwoLevelsDeep() throws IOException { + RenderResult result = jinjava.renderForResult( + Resources.toString( + Resources.getResource("tags/includetag/errors/base2.html"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getLineno()).isEqualTo(2); + assertThat(result.getErrors().get(0).getMessage()) + .contains("Error in `tags/includetag/errors/error.html` on line 4"); + + assertThat(result.getErrors().get(0).getSourceTemplate().isPresent()); + assertThat(result.getErrors().get(0).getSourceTemplate().get()) + .isEqualTo("tags/includetag/errors/error.html"); + } + + @Test + public void itAvoidsTagCycleExceptionInsideExtendedFiles() throws Exception { + String result = jinjava.render( + Resources.toString( + Resources.getResource("tags/extendstag/tagcycleexception/template-a.jinja"), + StandardCharsets.UTF_8 + ), + new HashMap<>() + ); + assertThat(result).isEqualTo("Extended text, will be rendered"); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/MacroTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/MacroTagTest.java index 80cf2a952..0d669f5ca 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/MacroTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/MacroTagTest.java @@ -3,112 +3,175 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import com.google.common.base.Throwables; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import com.hubspot.jinjava.lib.fn.MacroFunction; import com.hubspot.jinjava.tree.Node; import com.hubspot.jinjava.tree.TagNode; import com.hubspot.jinjava.tree.TreeParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.junit.Test; -public class MacroTagTest { - - Context context; - JinjavaInterpreter interpreter; - - @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); - JinjavaInterpreter.pushCurrent(interpreter); - - context = interpreter.getContext(); - } - - @After - public void cleanup() { - JinjavaInterpreter.popCurrent(); - } +public class MacroTagTest extends BaseInterpretingTest { @Test public void testSimpleFn() { TagNode t = fixture("simple"); - assertThat(t.render(interpreter)).isEmpty(); + assertThat(t.render(interpreter).getValue()).isEmpty(); - MacroFunction fn = (MacroFunction) interpreter.resolveObject("__macros__.getPath", -1); + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.getPath", + -1, + -1 + ); assertThat(fn.getName()).isEqualTo("getPath"); assertThat(fn.getArguments()).isEmpty(); assertThat(fn.isCaller()).isFalse(); - assertThat(fn.isCatchKwargs()).isFalse(); - assertThat(fn.isCatchVarargs()).isFalse(); context.put("myname", "jared"); - assertThat(snippet("{{ getPath() }}").render(interpreter).trim()).isEqualTo("Hello jared"); + assertThat(snippet("{{ getPath() }}").render(interpreter).getValue()) + .isEqualTo("Hello jared"); } @Test public void testFnWithArgs() { TagNode t = fixture("with-args"); - assertThat(t.render(interpreter)).isEmpty(); + assertThat(t.render(interpreter).getValue()).isEmpty(); + + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.section_link", + -1, + -1 + ); + assertThat(fn.getName()).isEqualTo("section_link"); + assertThat(fn.getArguments()).containsExactly("link", "text"); + + assertThat( + snippet("{{section_link('mylink', 'mytext')}}") + .render(interpreter) + .getValue() + .trim() + ) + .isEqualTo("link: mylink, text: mytext"); + } + + @Test + public void testFnWithDeferredArgs() { + TagNode t = fixture("with-args"); + assertThat(t.render(interpreter).getValue()).isEmpty(); - MacroFunction fn = (MacroFunction) interpreter.resolveObject("__macros__.section_link", -1); + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.section_link", + -1, + -1 + ); assertThat(fn.getName()).isEqualTo("section_link"); assertThat(fn.getArguments()).containsExactly("link", "text"); - assertThat(snippet("{{section_link('mylink', 'mytext')}}").render(interpreter).trim()).isEqualTo("link: mylink, text: mytext"); + interpreter.getContext().put("mylink", DeferredValue.instance()); + assertThat( + snippet("{{section_link(mylink, 'mytext')}}").render(interpreter).getValue().trim() + ) + .isEqualTo("{{section_link(mylink, 'mytext')}}"); + } + + @Test + public void testFnWithKwArgs() { + TagNode t = fixture("list_kwargs"); + assertThat(t.render(interpreter).getValue()).isEmpty(); + + String out = snippet("{{ list_kwargs(foo=\"bar\", bas='baz', answer=42) }}") + .render(interpreter) + .getValue() + .trim(); + assertThat(out).contains("foo=bar"); + assertThat(out).contains("bas=baz"); + assertThat(out).contains("answer=42"); } @Test public void testFnWithArgsWithDefVals() { TagNode t = fixture("def-vals"); - assertThat(t.render(interpreter)).isEmpty(); + assertThat(t.render(interpreter).getValue()).isEmpty(); - MacroFunction fn = (MacroFunction) interpreter.resolveObject("__macros__.article", -1); - assertThat(fn.getArguments()).containsExactly("title", "thumb", "link", "summary", "last"); + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.article", + -1, + -1 + ); + assertThat(fn.getArguments()) + .containsExactly("title", "thumb", "link", "summary", "last"); assertThat(fn.getDefaults()).contains(entry("last", false)); - assertThat(snippet("{{ article('mytitle','mythumb','mylink','mysummary') }}").render(interpreter).trim()) - .isEqualTo("title: mytitle, thumb: mythumb, link: mylink, summary: mysummary, last: false"); - assertThat(snippet("{{ article('mytitle','mythumb','mylink','mysummary', last=true) }}").render(interpreter).trim()) - .isEqualTo("title: mytitle, thumb: mythumb, link: mylink, summary: mysummary, last: true"); + assertThat( + snippet("{{ article('mytitle','mythumb','mylink','mysummary') }}") + .render(interpreter) + .getValue() + .trim() + ) + .isEqualTo( + "title: mytitle, thumb: mythumb, link: mylink, summary: mysummary, last: false" + ); + assertThat( + snippet("{{ article('mytitle','mythumb','mylink','mysummary', last=true) }}") + .render(interpreter) + .getValue() + .trim() + ) + .isEqualTo( + "title: mytitle, thumb: mythumb, link: mylink, summary: mysummary, last: true" + ); } @Test public void testFnWithArrayDefVal() { TagNode t = fixture("array-def-val"); - assertThat(t.render(interpreter)).isEmpty(); + assertThat(t.render(interpreter).getValue()).isEmpty(); - MacroFunction fn = (MacroFunction) interpreter.resolveObject("__macros__.prefix", -1); - assertThat(fn.getArguments()).containsExactly("property", "value", "prefixes", "prefixval"); - assertThat(fn.getDefaults()).contains(entry("prefixes", Lists.newArrayList("webkit", "moz"))); + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.prefix", + -1, + -1 + ); + assertThat(fn.getArguments()) + .containsExactly("property", "value", "prefixes", "prefixval"); + assertThat(fn.getDefaults()) + .contains(entry("prefixes", Lists.newArrayList("webkit", "moz"))); } @Test public void testMacroUsedInForLoop() throws Exception { Map bindings = new HashMap<>(); - bindings.put("widget_data", ImmutableMap.of( - "tools_body_1", ImmutableMap.of("html", "body1"), - "tools_body_2", ImmutableMap.of("html", "body2"), - "tools_body_3", ImmutableMap.of("html", "body3"), - "tools_body_4", ImmutableMap.of("html", "body4") - )); - - Document dom = Jsoup.parseBodyFragment(new Jinjava().render(Resources.toString(Resources.getResource(String.format("tags/macrotag/%s.jinja", "macro-used-in-forloop")), StandardCharsets.UTF_8), bindings)); + bindings.put( + "widget_data", + ImmutableMap.of( + "tools_body_1", + ImmutableMap.of("html", "body1"), + "tools_body_2", + ImmutableMap.of("html", "body2"), + "tools_body_3", + ImmutableMap.of("html", "body3"), + "tools_body_4", + ImmutableMap.of("html", "body4") + ) + ); + + Document dom = Jsoup.parseBodyFragment( + new Jinjava().render(fixtureText("macro-used-in-forloop"), bindings) + ); Element tabs = dom.select(".tabs").get(0); assertThat(tabs.select(".tools__description")).hasSize(4); assertThat(tabs.select(".tools__description").get(0).text()).isEqualTo("body1"); @@ -117,16 +180,241 @@ public void testMacroUsedInForLoop() throws Exception { assertThat(tabs.select(".tools__description").get(3).text()).isEqualTo("body4"); } + @Test + public void itPreventsDirectMacroRecursion() throws IOException { + String template = fixtureText("recursion"); + interpreter.render(template); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .contains("Cycle detected for macro 'hello'"); + } + + @Test + public void itPreventsIndirectMacroRecursion() throws IOException { + String template = fixtureText("recursion_indirect"); + interpreter.render(template); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .contains("Cycle detected for macro 'goodbye'"); + } + + @Test + public void itAllowsMacrosCallingMacrosUsingCall() throws IOException { + String template = fixtureText("macros-calling-macros"); + String out = interpreter.render(template); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(out).contains("Hello World One"); + assertThat(out).contains("Hello World Two"); + } + + @Test + public void itAllowsMacroRecursionWhenEnabledInConfiguration() throws IOException { + // I need a different configuration here therefore + interpreter = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withEnableRecursiveMacroCalls(true).build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + + try { + String template = fixtureText("ending-recursion"); + String out = interpreter.render(template); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(out).contains("Hello Hello Hello Hello Hello"); + } finally { + // and I need to cleanup my mess... + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itAllowsMacroRecursionWithMaxDepth() throws IOException { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(10) + .build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + + try { + String template = fixtureText("ending-recursion"); + String out = interpreter.render(template); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(out).contains("Hello Hello Hello Hello Hello"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itAllowsMacroRecursionWithMaxDepthInValidationMode() throws IOException { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(10) + .withValidationMode(true) + .build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + + try { + String template = fixtureText("ending-recursion"); + String out = interpreter.render(template); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + assertThat(out).contains("Hello Hello Hello Hello Hello"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itEnforcesMacroRecursionWithMaxDepth() throws IOException { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(2) + .build() + ) + .newInterpreter(); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + String template = fixtureText("ending-recursion"); + String out = interpreter.render(template); + assertThat(interpreter.getErrorsCopy().get(0).getMessage()) + .contains("Max recursion limit of 2 reached for macro 'hello'"); + assertThat(out).contains("Hello Hello"); + } + } + + @Test + public void itPreventsRecursionForMacroWithVar() { + interpreter = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withNestedInterpretationEnabled(true).build() + ) + .newInterpreter(); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + String jinja = + "{%- macro func(var) %}" + + "{%- for k,f in var.items() %}" + + "{{ f.val }}" + + "{%- endfor %}" + + "{%- endmacro %}" + + "{%- set var = {" + + " 'f' : {" + + " 'val': '{{ self }}'," + + " }" + + "} %}" + + "{% set self='{{var}}' %}" + + "{{ func(var) }}" + + ""; + Node node = new TreeParser(interpreter, jinja).buildTree(); + assertThat(interpreter.render(node)) + .isEqualTo("{'f': {'val': '{'f': {'val': '{{ self }}'} }'} }"); + } + } + + @Test + public void itReconstructsMacroDefinitionFromMacroFunction() { + TagNode t = fixture("simple"); + assertThat(t.render(interpreter).getValue()).isEmpty(); + + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.getPath", + -1, + -1 + ); + assertThat(fn.reconstructImage()).isEqualTo(fixtureText("simple").trim()); + } + + @Test + public void itReconstructsMacroDefinitionFromMacroFunctionWithNoTrim() { + TagNode t = fixture("simple-no-trim"); + assertThat(t.render(interpreter).getValue()).isEmpty(); + + MacroFunction fn = (MacroFunction) interpreter.resolveObject( + "__macros__.getPath", + -1, + -1 + ); + assertThat(fn.reconstructImage()).isEqualTo(fixtureText("simple-no-trim").trim()); + } + + @Test + public void itCorrectlyScopesNestedMacroTags() { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(2) + .build() + ) + .newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + try { + String result = interpreter.render(fixtureText("scoping")); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.SYNTAX_ERROR); + assertThat(interpreter.getErrors().get(0).getMessage()) + .isEqualTo("Could not resolve function 'bar'"); + assertThat(result.trim()) + .isEqualTo("parent & child & the bar.\nparent & child & the bar.\n."); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itCallsMacroInTernaryWithVariableCondition() { + String template = + "{% macro greet(name) %}Hello {{ name }}{% endmacro %}" + + "{{ greet('world') if myVar else greet('nobody') }}"; + + context.put("myVar", true); + assertThat(jinjava.render(template, context).trim()).isEqualTo("Hello world"); + + context.put("myVar", false); + assertThat(jinjava.render(template, context).trim()).isEqualTo("Hello nobody"); + } + + @Test + public void itCallsMacroInStandardTernaryWithVariableCondition() { + String template = + "{% macro greet(name) %}Hello {{ name }}{% endmacro %}" + + "{{ myVar ? greet('world') : greet('nobody') }}"; + + context.put("myVar", true); + assertThat(jinjava.render(template, context).trim()).isEqualTo("Hello world"); + + context.put("myVar", false); + assertThat(jinjava.render(template, context).trim()).isEqualTo("Hello nobody"); + } + private Node snippet(String jinja) { return new TreeParser(interpreter, jinja).buildTree().getChildren().getFirst(); } - private TagNode fixture(String name) { + private String fixtureText(String name) { try { - return (TagNode) snippet(Resources.toString(Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), StandardCharsets.UTF_8)); + return Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s.jinja", name)), + StandardCharsets.UTF_8 + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } + private TagNode fixture(String name) { + return (TagNode) snippet(fixtureText(name)); + } } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/RawTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/RawTagTest.java index f36e8e3b3..542d20e73 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/RawTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/RawTagTest.java @@ -2,31 +2,30 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.PreserveRawExecutionMode; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.TreeParser; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; - import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.tree.Node; -import com.hubspot.jinjava.tree.TagNode; -import com.hubspot.jinjava.tree.TreeParser; +public class RawTagTest extends BaseInterpretingTest { -public class RawTagTest { - - JinjavaInterpreter interpreter; - RawTag tag; + Tag tag; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); tag = new RawTag(); } @@ -53,44 +52,166 @@ public void renderTags() { public void renderHublSnippet() { TagNode tagNode = fixture("hubl"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("

    Blog Posts

      {% for content in contents %}
    • {{ content.name|title }}
    • {% endfor %}
    "); + .isEqualTo( + "

    Blog Posts

      {% for content in contents %}
    • {{ content.name|title }}
    • {% endfor %}
    " + ); } @Test public void itDoesntProcessUnknownTagsWithinARawBlock() { TagNode tagNode = fixture("unknowntags"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("{% footag %}{% bartag %}"); + .isEqualTo("{% footag %}{% bartag %}"); } @Test public void itWorksWithInvalidSyntaxWithinRawBlock() { TagNode tagNode = fixture("invalidsyntax"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("this is {invalid and wrong"); + .isEqualTo("this is {invalid and wrong"); tagNode = fixture("invalidsyntax2"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("this is {{ invalid and wrong"); + .isEqualTo("this is {{ invalid and wrong"); tagNode = fixture("invalidsyntax3"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("this is }invalid and wrong"); + .isEqualTo("this is }invalid and wrong"); tagNode = fixture("invalidsyntax4"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("this is }} invalid and wrong"); + .isEqualTo("this is }} invalid and wrong"); tagNode = fixture("invalidsyntax5"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .isEqualTo("this is {% invalid and wrong"); + .isEqualTo("this is {% invalid and wrong"); } @Test public void itDoesntProcessJinjaCommentsWithinARawBlock() { TagNode tagNode = fixture("comment"); assertThat(StringUtils.normalizeSpace(tag.interpret(tagNode, interpreter))) - .contains("{{#each people}}"); + .contains("{{#each people}}"); + } + + @Test + public void itPreservesRawTags() { + TagNode tagNode = fixture("hubl"); + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build() + ); + String result = tag.interpret(tagNode, preserveInterpreter); + try { + assertThat(result) + .isEqualTo( + Resources.toString( + Resources.getResource("tags/rawtag/hubl.jinja"), + StandardCharsets.UTF_8 + ) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void itOverridesRawTagPreservation() { + TagNode tagNode = fixture("hubl"); + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build() + ); + String result; + try ( + TemporaryValueClosable c = preserveInterpreter + .getContext() + .withUnwrapRawOverride() + ) { + result = tag.interpret(tagNode, preserveInterpreter); + } + assertThat(StringUtils.normalizeSpace(result)) + .isEqualTo( + "

    Blog Posts

      {% for content in contents %}
    • {{ content.name|title }}
    • {% endfor %}
    " + ); + } + + @Test + public void ifFixesSpacingWithRawTagPreservation() { + TagNode tagNode = fixture("nospacing"); + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build() + ); + String result = tag.interpret(tagNode, preserveInterpreter); + assertThat(StringUtils.normalizeSpace(result)) + .isEqualTo("{% raw %} {%print 'foo'%} {% endraw %}"); + } + + @Test + public void itPreservesDeferredWhilePreservingRawTags() { + TagNode tagNode = fixture("deferred"); + JinjavaInterpreter preserveInterpreter = new JinjavaInterpreter( + jinjava, + jinjava.getGlobalContextCopy(), + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build() + ); + preserveInterpreter.getContext().put("deferred", DeferredValue.instance()); + interpreter.getContext().put("deferred", DeferredValue.instance()); + + String preservedResult = tag.interpret(tagNode, preserveInterpreter); + String nonPreservedResult = tag.interpret(tagNode, interpreter); + try { + assertThat(preservedResult) + .isEqualTo( + Resources + .toString( + Resources.getResource("tags/rawtag/deferred.jinja"), + StandardCharsets.UTF_8 + ) + .trim() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + assertThat(nonPreservedResult).isEqualTo("{{ deferred }}"); + + // Should not get evaluated because it's wrapped in a raw tag. + String deferredRealValue = "Resolved value."; + preserveInterpreter.getContext().put("deferred", deferredRealValue); + interpreter.getContext().put("deferred", deferredRealValue); + String preservedIdempotent = tag.interpret( + (TagNode) new TreeParser(preserveInterpreter, preservedResult) + .buildTree() + .getChildren() + .getFirst(), + preserveInterpreter + ); + String secondPass = tag.interpret( + (TagNode) new TreeParser(interpreter, preservedResult) + .buildTree() + .getChildren() + .getFirst(), + interpreter + ); + + assertThat(preservedIdempotent).isEqualTo(preservedResult); + assertThat(secondPass).isEqualTo("{{ deferred }}"); } private TagNode fixture(String name) { @@ -99,12 +220,17 @@ private TagNode fixture(String name) { private LinkedList fixtures(String name) { try { - return new TreeParser(interpreter, Resources.toString( - Resources.getResource(String.format("tags/rawtag/%s.jinja", name)), StandardCharsets.UTF_8)) - .buildTree().getChildren(); + return new TreeParser( + interpreter, + Resources.toString( + Resources.getResource(String.format("tags/rawtag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ) + .buildTree() + .getChildren(); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/SetTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/SetTagTest.java index df4c356d3..5d4650a63 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/SetTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/SetTagTest.java @@ -1,44 +1,34 @@ package com.hubspot.jinjava.lib.tag; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.entry; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.TreeParser; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; - import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.runners.MockitoJUnitRunner; - -import com.google.common.base.Throwables; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.tree.Node; -import com.hubspot.jinjava.tree.TagNode; -import com.hubspot.jinjava.tree.TreeParser; @SuppressWarnings("unchecked") -@RunWith(MockitoJUnitRunner.class) -public class SetTagTest { - - @InjectMocks - SetTag tag; +public class SetTagTest extends BaseInterpretingTest { - Context context; - JinjavaInterpreter interpreter; + public Tag tag; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); - context = interpreter.getContext(); + tag = new SetTag(); } @Test @@ -51,7 +41,7 @@ public void itReturnsBlankString() throws Exception { public void itAssignsValToVar() throws Exception { TagNode tagNode = (TagNode) fixture("set-val"); tag.interpret(tagNode, interpreter); - assertThat(interpreter.resolveObject("primary_line_height", -1)).isEqualTo(42L); + assertThat(interpreter.resolveObject("primary_line_height", -1, -1)).isEqualTo(42L); } @Test @@ -59,7 +49,7 @@ public void itAssignsVarToVar() throws Exception { context.put("primary_font_size_num", 10); TagNode tagNode = (TagNode) fixture("set-var-exp"); tag.interpret(tagNode, interpreter); - assertThat(interpreter.resolveObject("primary_line_height", -1)).isEqualTo(15.0); + assertThat(interpreter.resolveObject("primary_line_height", -1, -1)).isEqualTo(15.0); } @Test @@ -78,10 +68,19 @@ public void itHandlesComplexDictWithConcats() throws Exception { TagNode tagNode = (TagNode) fixture("set-complex-dict"); tag.interpret(tagNode, interpreter); - Map dict = (Map) interpreter.resolveObject("styles", -1); - assertThat(dict).contains( - entry("heading", "color:bluecolor;text-shadow:2px 2px 1px rgba(0,0,0,.1);font-family:fontfam"), - entry("module", "background:#ffffff;padding:123px;border:1px solid 10")); + Map dict = (Map) interpreter.resolveObject( + "styles", + -1, + -1 + ); + assertThat(dict) + .contains( + entry( + "heading", + "color:bluecolor;text-shadow:2px 2px 1px rgba(0,0,0,.1);font-family:fontfam" + ), + entry("module", "background:#ffffff;padding:123px;border:1px solid 10") + ); } @Test @@ -89,7 +88,8 @@ public void itConcatsStringsInExprs() throws Exception { context.put("lw_font_size_base", 42); TagNode tagNode = (TagNode) fixture("set-string-concat"); tag.interpret(tagNode, interpreter); - assertThat(interpreter.resolveObject("lw_font_size", -1)).isEqualTo("font-size: 42px; "); + assertThat(interpreter.resolveObject("lw_font_size", -1, -1)) + .isEqualTo("font-size: 42px; "); } @Test @@ -97,7 +97,8 @@ public void itConcatsStringsWithNestedExprs() throws Exception { context.put("lw_font_size_base", 10); TagNode tagNode = (TagNode) fixture("set-expr-concat"); tag.interpret(tagNode, interpreter); - assertThat(interpreter.resolveObject("lw_secondary_font_size", -1)).isEqualTo("font-size: 8px; "); + assertThat(interpreter.resolveObject("lw_secondary_font_size", -1, -1)) + .isEqualTo("font-size: 8px; "); } @Test @@ -105,7 +106,7 @@ public void itSupportsExpressionsWithFilters() throws Exception { context.put("max_size", "470"); TagNode tagNode = (TagNode) fixture("set-filter-expr"); tag.interpret(tagNode, interpreter); - assertThat(interpreter.resolveObject("ie_max_size", -1)).isEqualTo(440L); + assertThat(interpreter.resolveObject("ie_max_size", -1, -1)).isEqualTo(440L); } @Test @@ -114,7 +115,7 @@ public void itSupportsArraySyntax() throws Exception { TagNode tagNode = (TagNode) fixture("set-array"); tag.interpret(tagNode, interpreter); - List result = (List) interpreter.resolveObject("the_list", -1); + List result = (List) interpreter.resolveObject("the_list", -1, -1); assertThat(result).containsExactly(1L, 2L, 3L, 7L); } @@ -125,7 +126,11 @@ public void itSupportsDictionarySyntax() throws Exception { TagNode tagNode = (TagNode) fixture("set-dictionary"); tag.interpret(tagNode, interpreter); - Map result = (Map) interpreter.resolveObject("the_dictionary", -1); + Map result = (Map) interpreter.resolveObject( + "the_dictionary", + -1, + -1 + ); assertThat(result).contains(entry("seven", 7L)); } @@ -140,14 +145,194 @@ public void itSupportsListAppendFunc() throws Exception { assertThat(thelist).containsExactly("foo", "bar"); } + @Test + public void itSupportsListSetFunc() throws Exception { + context.put("show_count", Lists.newArrayList("foo")); + context.put("show", "bar"); + TagNode tagNode = (TagNode) fixture("set-list-modify"); + tag.interpret(tagNode, interpreter); + + List thelist = (List) context.get("show_count"); + assertThat(thelist).containsExactly("bar"); + } + + @Test + public void itSupportsMultiVar() throws Exception { + context.put("bar", "mybar"); + + TagNode tagNode = (TagNode) fixture("set-multivar"); + tag.interpret(tagNode, interpreter); + + assertThat(context) + .contains( + entry("myvar1", "foo"), + entry("myvar2", "mybar"), + entry("myvar3", Lists.newArrayList(1L, 2L, 3L, 4L)), + entry("myvar4", "yoooooo") + ); + } + + @Test(expected = TemplateSyntaxException.class) + public void itThrowsErrorWhenMultiVarIsUnbalancedForVars() throws Exception { + context.put("bar", "mybar"); + + TagNode tagNode = (TagNode) fixture("set-multivar-unbalanced-vars"); + tag.interpret(tagNode, interpreter); + + assertThat(context) + .contains( + entry("myvar1", "foo"), + entry("myvar2", "mybar"), + entry("myvar3", Lists.newArrayList(1L, 2L, 3L, 4L)), + entry("myvar4", "yoooooo") + ); + } + + @Test(expected = TemplateSyntaxException.class) + public void itThrowsErrorWhenMultiVarIsUnbalancedForVals() throws Exception { + context.put("bar", "mybar"); + + TagNode tagNode = (TagNode) fixture("set-multivar-unbalanced-vals"); + tag.interpret(tagNode, interpreter); + + assertThat(context) + .contains( + entry("myvar1", "foo"), + entry("myvar2", "mybar"), + entry("myvar3", Lists.newArrayList(1L, 2L, 3L, 4L)), + entry("myvar4", "yoooooo") + ); + } + + @Test + public void itThrowsAndDefersVarWhenValContainsDeferred() { + context.put("primary_font_size_num", DeferredValue.instance()); + + TagNode tagNode = (TagNode) fixture("set-var-exp"); + + assertThatThrownBy(() -> tag.interpret(tagNode, interpreter)) + .isInstanceOf(DeferredValueException.class); + assertThat(context).contains(entry("primary_line_height", DeferredValue.instance())); + } + + @Test + public void itThrowsAndDefersMultiVarWhenValContainsDeferred() { + context.put("bar", DeferredValue.instance()); + + TagNode tagNode = (TagNode) fixture("set-multivar"); + + assertThatThrownBy(() -> tag.interpret(tagNode, interpreter)) + .isInstanceOf(DeferredValueException.class); + assertThat(context) + .contains( + entry("myvar1", DeferredValue.instance()), + entry("myvar2", DeferredValue.instance()), + entry("myvar3", DeferredValue.instance()), + entry("myvar4", DeferredValue.instance()) + ); + } + + @Test + public void shouldSetNamespaceVariable() { + String template = "{% set ns = namespace(found=false) %}" + "Result: {{ns.found}}"; + String result = interpreter.render(template); + assertThat(result).isEqualTo("Result: false"); + } + + @Test + public void shouldSetAFewVariablesInNamespace() { + String template = + "{% set ns = namespace(found=false) %}" + + "{% set ns.count=3 %}" + + "Found: {{ns.found}}, Count: {{ns.count}}"; + String result = interpreter.render(template); + assertThat(result).isEqualTo("Found: false, Count: 3"); + } + + @Test + public void shouldUpdateNamespaceVariableWithTheSameDataType() { + String template = + "{% set ns = namespace(found=false) %}" + + "{% set ns.found=true %}" + + "Result: {{ns.found}}"; + + String result = interpreter.render(template); + assertThat(result).isEqualTo("Result: true"); + } + + @Test + public void shouldUpdateNamespaceVariableWithDifferentDataType() { + String template = + "{% set ns = namespace(found=false) %}" + + "{% set ns.found=true %}" + + "{% set ns.found=1 %}" + + "Result: {{ns.found + 1}}"; + context.put("items", Lists.newArrayList("A", "B")); + String result = interpreter.render(template); + assertThat(result).isEqualTo("Result: 2"); + } + + @Test + public void itCreatesNamespaceWithDictionary() { + Map dict = new HashMap<>(); + dict.put("foo", "bar"); + context.put("dict", dict); + String template = + "{% set ns = namespace(dict, foobar='baz') %}" + "{{ ns.foo ~ ns.foobar}}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("barbaz"); + } + + @Test + public void itSetsBlock() { + String template = "{% set foo %}bar{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("bar"); + } + + @Test + public void itSetsBlockWithFilter() { + String template = "{% set foo | upper %}bar{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("BAR"); + } + + @Test + public void itRunsSetBlockInAChildScope() { + String template = + "{% set bar = 1 %}{% set foo %}{% set bar = 2 %}{% endset %}{{ bar }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("1"); + } + + @Test + public void itDoesNotRunSetBlockInAChildScopeForIgnoredVariableName() { + // This is to preserve legacy behaviour used in Eager Execution + String template = + "{% set bar = 1 %}{% set __ignored__ %}{% set bar = 2 %}{% endset %}{{ bar }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("2"); + } + private Node fixture(String name) { try { - return new TreeParser(interpreter, Resources.toString( - Resources.getResource(String.format("tags/settag/%s.jinja", name)), StandardCharsets.UTF_8)) - .buildTree().getChildren().getFirst(); + return new TreeParser( + interpreter, + Resources.toString( + Resources.getResource(String.format("tags/settag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ) + .buildTree() + .getChildren() + .getFirst(); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/TagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/TagTest.java index 62999c18a..fc76db597 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/TagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/TagTest.java @@ -18,16 +18,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import java.util.HashMap; -import java.util.Map; - -import org.junit.Before; -import org.junit.Test; - import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.RenderResult; import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; public class TagTest { @@ -98,6 +96,13 @@ public void forLoop4() { assertEquals("3-232-451-450-689", res); } + @Test + public void forLoop5() { + String s = "{% for item in var2|list %}
  • {{ item }}
  • {% endfor %}"; + res = jinjava.render(s, bindings); + assertEquals("
  • 4
  • 5
  • ", res); + } + @Test public void reverseFor() { script = "{% for item in var1|reverse %}{{item}}{% endfor%}"; @@ -107,7 +112,8 @@ public void reverseFor() { @Test public void ifchangedFor() { - script = "{% for item in var1|reverse %}{%ifchanged item%}{{item}}{%endifchanged%}{% endfor%}"; + script = + "{% for item in var1|reverse %}{%ifchanged item%}{{item}}{%endifchanged%}{% endfor%}"; res = jinjava.render(script, bindings); assertEquals("6894523", res); } @@ -135,7 +141,8 @@ public void cycleFor1() { @Test public void cycleFor2() { - script = "{% cycle var3,var2,'hello' as d%}{% for item in var1 %}{%cycle d%}{% endfor%}"; + script = + "{% cycle var3,var2,'hello' as d%}{% for item in var1 %}{%cycle d%}{% endfor%}"; res = jinjava.render(script, bindings); assertEquals("1245hello12", res); } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/UnlessTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/UnlessTagTest.java index e4121c13e..877e9498a 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/UnlessTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/UnlessTagTest.java @@ -2,32 +2,21 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.TreeParser; import java.io.IOException; import java.nio.charset.StandardCharsets; - import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.tree.TagNode; -import com.hubspot.jinjava.tree.TreeParser; - -public class UnlessTagTest { - - JinjavaInterpreter interpreter; - UnlessTag tag; +public class UnlessTagTest extends BaseInterpretingTest { - Context context; + public Tag tag; @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); - context = interpreter.getContext(); - + public void setupTag() { tag = new UnlessTag(); } @@ -47,12 +36,18 @@ public void itDoesntEvalChildrenWhenExprIsTrue() throws Exception { private TagNode fixture(String name) { try { - return (TagNode) new TreeParser(interpreter, Resources.toString( - Resources.getResource(String.format("tags/iftag/%s.jinja", name)), StandardCharsets.UTF_8)) - .buildTree().getChildren().getFirst(); + return (TagNode) new TreeParser( + interpreter, + Resources.toString( + Resources.getResource(String.format("tags/iftag/%s.jinja", name)), + StandardCharsets.UTF_8 + ) + ) + .buildTree() + .getChildren() + .getFirst(); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ValidationModeTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ValidationModeTest.java new file mode 100644 index 000000000..3e0b1438f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ValidationModeTest.java @@ -0,0 +1,325 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.testobjects.ValidationModeTestObjects; +import com.hubspot.jinjava.testobjects.ValidationModeTestObjects; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TextNode; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.TextToken; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ValidationModeTest { + + JinjavaInterpreter interpreter; + JinjavaInterpreter validatingInterpreter; + + Jinjava jinjava; + private Context context; + + ValidationModeTestObjects.ValidationFilter validationFilter; + + private static int functionExecutionCount = 0; + + public static int validationTestFunction() { + return ++functionExecutionCount; + } + + @Before + public void setup() { + validationFilter = new ValidationModeTestObjects.ValidationFilter(); + + ELFunctionDefinition validationFunction = new ELFunctionDefinition( + "", + "validation_test", + ValidationModeTest.class, + "validationTestFunction" + ); + + jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); + jinjava.getGlobalContext().registerFilter(validationFilter); + jinjava.getGlobalContext().registerFunction(validationFunction); + interpreter = jinjava.newInterpreter(); + context = interpreter.getContext(); + + validatingInterpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides(LegacyOverrides.NONE) + .withValidationMode(true) + .build() + ); + } + + @Test + public void itResolvesAllIfExpressionsInValidationMode() { + validatingInterpreter.render( + "{{ badCode( }}" + "{% if false %}" + " {{ badCode( }}" + "{% endif %}" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + } + + @Test + public void itResolvesAllUnlessExpressionsInValidationMode() { + validatingInterpreter.render( + "{{ badCode( }}" + "{% unless false %}" + " {{ badCode( }}" + "{% endunless %}" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + } + + @Test + public void itResolvesAllForExpressionsInValidationMode() { + validatingInterpreter.render( + "{{ badCode( }}" + "{% for i in [1, 2, 3] %}" + " {{ badCode( }}" + "{% endfor %}" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + } + + @Test + public void itResolvesNestedForExpressionsInValidationMode() { + String output = validatingInterpreter.render( + "{{ badCode( }}" + + "{% for i in [] %}" + + " outer loop" + + " {% for i in [1, 2, 3] %}" + + " inner loop {{ badCode( }}" + + " {% endfor %}" + + "{% endfor %}" + + "done" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + assertThat(output.trim()).isEqualTo("done"); + } + + @Test + public void itResolvesZeroLoopForExpressionsInValidationMode() { + String output = validatingInterpreter.render( + "{{ badCode( }}" + + "{% for i in [] %}" + + "in loop {{ badCode( }}" + + "{% endfor %}" + + "hi" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + assertThat(output.trim()).isEqualTo("hi"); + } + + @Test + public void itAllowsPropertyReferenceInForLoopInValidationMode() { + String output = validatingInterpreter.render( + "{% for i in [] %}" + "{{ i.test }}" + "{% endfor %}" + "hi" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(0); + assertThat(output.trim()).isEqualTo("hi"); + } + + @Test + public void itAllowsPropertyReferenceAndTypeCoercionInForLoopInValidationMode() { + String output = validatingInterpreter.render( + "{% for i in [] %}" + + "{{ i.test + 100 }}" + + "{{ i.nope ~ 'hello' }}" + + "{% endfor %}" + + "hi" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(0); + assertThat(output.trim()).isEqualTo("hi"); + } + + @Test + public void itResolvesZeroLoopTupleForExpressionsInValidationMode() { + String output = validatingInterpreter.render( + "{{ badCode( }}" + + "{% set map = {} %}" + + "{% for a, b in map.items() %}" + + "in loop {{ badCode( }}" + + "{% endfor %}" + + "hi" + ); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(2); + assertThat(output.trim()).isEqualTo("hi"); + } + + @Test + public void itDoesNotSetValuesInValidatedBlocks() { + String output = validatingInterpreter.render( + "{% set foo = \"orig value\" %}" + + "{% if false %}" + + " {% set foo = \"in false block\" %}" + + "{% endif %}" + + "{{ foo }}" + ); + + assertThat(output.trim()).isEqualTo("orig value"); + assertThat(validatingInterpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesNotSetValuesInNestedValidatedBlocks() { + String output = validatingInterpreter.render( + "{% set foo = \"orig value\" %}" + + "{% if false %}" + + " {% if true %}" + + " {% set foo = \"in nested block\" %}" + + " {% endif %}" + + "{% endif %}" + + "{{ foo }}" + ); + + assertThat(output.trim()).isEqualTo("orig value"); + assertThat(validatingInterpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesNotPrintValuesInNestedValidatedBlocks() { + String output = validatingInterpreter.render( + "hi " + + "{% if false %}" + + " hidey " + + " {% if true %}" + + " hey" + + " {% endif %}" + + "{% endif %}" + + "there" + ); + + assertThat(output.trim()).isEqualTo("hi there"); + assertThat(validatingInterpreter.getErrors()).isEmpty(); + } + + private class InstrumentedMacroFunction extends MacroFunction { + + private int invocationCount = 0; + + InstrumentedMacroFunction( + List content, + String name, + LinkedHashMap argNamesWithDefaults, + boolean caller, + Context localContextScope + ) { + super(content, name, argNamesWithDefaults, caller, localContextScope, -1, -1); + } + + @Override + public Object doEvaluate( + Map argMap, + Map kwargMap, + List varArgs + ) { + invocationCount++; + return super.doEvaluate(argMap, kwargMap, varArgs); + } + + int getInvocationCount() { + return invocationCount; + } + } + + @Test + public void itDoesNotExecuteMacrosInValidatedBlocks() { + TextNode textNode = new TextNode( + new TextToken("hello", 1, 1, new DefaultTokenScannerSymbols()) + ); + InstrumentedMacroFunction macro = new InstrumentedMacroFunction( + ImmutableList.of(textNode), + "hello", + new LinkedHashMap<>(), + false, + interpreter.getContext() + ); + interpreter.getContext().addGlobalMacro(macro); + String template = + "{{ hello() }}" + "{% if false %} " + " {{ hello() }}" + "{% endif %}"; + + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + assertThat(interpreter.getErrors()).isEmpty(); + assertThat(interpreter.render(template).trim()).isEqualTo("hello"); + assertThat(macro.getInvocationCount()).isEqualTo(1); + } + try (var a = JinjavaInterpreter.closeablePushCurrent(validatingInterpreter).get()) { + assertThat(validatingInterpreter.render(template).trim()).isEqualTo("hello"); + assertThat(macro.getInvocationCount()).isEqualTo(3); + assertThat(validatingInterpreter.getErrors()).isEmpty(); + } + } + + @Test + public void itDoesNotExecuteFunctionsInValidatedBlocks() { + functionExecutionCount = 0; + + assertThat(functionExecutionCount).isEqualTo(0); + + String template = + "{{ validation_test() }}" + + "{% if false %}" + + " {{ validation_test() }}" + + " {{ hey( }}" + + "{% endif %}"; + + String result = interpreter.render(template); + assertThat(interpreter.getErrors()).isEmpty(); + assertThat(result).isEqualTo("1"); + assertThat(functionExecutionCount).isEqualTo(1); + + result = validatingInterpreter.render(template); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(1); + assertThat(validatingInterpreter.getErrors().get(0).getMessage()).contains("hey("); + assertThat(result).isEqualTo("2"); + assertThat(functionExecutionCount).isEqualTo(2); + } + + @Test + public void itDoesNotExecuteFiltersInValidatedBlocks() { + assertThat(validationFilter.getExecutionCount()).isEqualTo(0); + + String template = + "{{ 10|validation_filter() }}" + + "{% if false %}" + + " {{ 10|validation_filter() }}" + + " {{ hey( }}" + + "{% endif %}"; + + String result = interpreter.render(template).trim(); + assertThat(interpreter.getErrors()).isEmpty(); + assertThat(result).isEqualTo("10"); + assertThat(validationFilter.getExecutionCount()).isEqualTo(1); + + try (var a = JinjavaInterpreter.closeablePushCurrent(validatingInterpreter).get()) { + result = validatingInterpreter.render(template).trim(); + + assertThat(validatingInterpreter.getErrors().size()).isEqualTo(1); + assertThat(validatingInterpreter.getErrors().get(0).getMessage()).contains("hey("); + assertThat(result).isEqualTo("10"); + assertThat(validationFilter.getExecutionCount()).isEqualTo(2); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTagTest.java new file mode 100644 index 000000000..0f4f84ad0 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerCycleTagTest.java @@ -0,0 +1,87 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.CycleTagTest; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.List; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerCycleTagTest extends CycleTagTest { + + private static final long MAX_OUTPUT_SIZE = 500L; + private Tag tag; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build() + ); + + tag = new EagerCycleTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + context.registerTag(new EagerForTag()); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itAddCycleTagAsADeferredToken() { + String template = + "{% for item in deferred %}{% cycle 'item-1','item-2' %}{% endfor %}"; + assertThat(interpreter.render(template)).isEqualTo(template); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken.isPresent()); + assertThat(maybeDeferredToken.get().getToken().getImage()) + .isEqualTo("{% cycle 'item-1','item-2' %}"); + } + + @Test + public void itHandlesDeferredCycle() { + String template = + "{% set l = [] %}{% for item in deferred %}{% cycle l.append(deferred),5 %}{% endfor %}{{ l }}"; + assertThat(interpreter.render(template)).isEqualTo(template); + } + + @Test + public void iitHandlesEscapedQuotesInVariable() { + String template = + "{% set class = \"class='foo bar'\" %}{% for item in deferred %}{% cycle 'item-1',class %}.{% endfor %}"; + String firstPass = interpreter.render(template); + assertThat(firstPass) + .isEqualTo( + "{% for item in deferred %}{% cycle 'item-1','class=\\'foo bar\\'' %}.{% endfor %}" + ); + interpreter.getContext().put("deferred", List.of(0, 1)); + String secondPass = interpreter.render(firstPass); + assertThat(secondPass).isEqualTo("item-1.class='foo bar'."); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTagTest.java new file mode 100644 index 000000000..6ef77d431 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerDoTagTest.java @@ -0,0 +1,72 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedNodeInterpreter; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.lib.tag.DoTagTest; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerDoTagTest extends DoTagTest { + + private static final long MAX_OUTPUT_SIZE = 500L; + private Tag tag; + private ExpectedNodeInterpreter expectedNodeInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build() + ); + + tag = new EagerDoTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + expectedNodeInterpreter = + new ExpectedNodeInterpreter(interpreter, tag, "tags/eager/dotag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itHandlesDeferredDo() { + context.put("foo", 2); + String template = "{% do deferred.append(foo*2) %}"; + assertThat(interpreter.render(template)).isEqualTo("{% do deferred.append(4) %}"); + } + + @Test + public void itLimitsLength() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < MAX_OUTPUT_SIZE; i++) { + tooLong.append(i); + } + context.setDeferredExecutionMode(true); + interpreter.render(String.format("{%% do deferred.append('%s') %%}", tooLong)); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.OUTPUT_TOO_BIG); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerExtendsTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerExtendsTagTest.java new file mode 100644 index 000000000..93d6c4916 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerExtendsTagTest.java @@ -0,0 +1,166 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Java6Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedTemplateInterpreter; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.ExtendsTagTest; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import java.io.IOException; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerExtendsTagTest extends ExtendsTagTest { + + private ExpectedTemplateInterpreter expectedTemplateInterpreter; + + @Before + public void eagerSetup() { + eagerSetup(false); + } + + void eagerSetup(boolean nestedInterpretation) { + JinjavaInterpreter.popCurrent(); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withNestedInterpretationEnabled(nestedInterpretation) + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build() + ); + context.put("deferred", DeferredValue.instance()); + expectedTemplateInterpreter = + new ExpectedTemplateInterpreter(jinjava, interpreter, "tags/eager/extendstag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itDefersBlockInExtendsChild() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "defers-block-in-extends-child" + ); + } + + @Test + public void itDefersBlockInExtendsChildSecondPass() { + context.put("deferred", "Resolved now"); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-block-in-extends-child.expected" + ); + context.remove(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-block-in-extends-child.expected" + ); + } + + @Test + public void itDefersSuperBlockWithDeferred() { + eagerSetup(true); + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "defers-super-block-with-deferred" + ); + } + + @Test + public void itDefersSuperBlockWithDeferredSecondPass() { + eagerSetup(true); + context.put("deferred", "Resolved now"); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "defers-super-block-with-deferred.expected" + ); + } + + @Test + public void itDefersSuperBlockWithDeferredNestedInterp() { + eagerSetup(true); + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "defers-super-block-with-deferred-nested-interp" + ); + } + + @Test + public void itDefersSuperBlockWithDeferredNestedInterpSecondPass() { + eagerSetup(true); + context.put("deferred", "Resolved now"); + expectedTemplateInterpreter.assertExpectedOutput( + "defers-super-block-with-deferred-nested-interp.expected" + ); + } + + @Test + public void itReconstructsDeferredOutsideBlock() { + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent( + "reconstructs-deferred-outside-block" + ); + } + + @Test + public void itReconstructsDeferredOutsideBlockSecondPass() { + context.put("deferred", "Resolved now"); + + expectedTemplateInterpreter.assertExpectedOutput( + "reconstructs-deferred-outside-block.expected" + ); + context.remove(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY); + expectedTemplateInterpreter.assertExpectedNonEagerOutput( + "reconstructs-deferred-outside-block.expected" + ); + } + + @Test + public void itThrowsWhenDeferredExtendsTag() { + interpreter.render( + expectedTemplateInterpreter.getFixtureTemplate("throws-when-deferred-extends-tag") + ); + assertThat(interpreter.getContext().getDeferredNodes()).hasSize(2); + } + + @Override + @Ignore + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksInExtendingTemplate() + throws IOException { + super.itSetsErrorLineNumbersCorrectlyInBlocksInExtendingTemplate(); + } + + @Override + @Ignore + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplate() + throws IOException { + super.itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplate(); + } + + @Override + @Ignore + @Test + public void itSetsErrorLineNumbersCorrectlyOutsideBlocksFromExtendedTemplate() + throws IOException { + super.itSetsErrorLineNumbersCorrectlyOutsideBlocksFromExtendedTemplate(); + } + + @Override + @Ignore + @Test + public void itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplateInIncludedTemplate() + throws IOException { + super.itSetsErrorLineNumbersCorrectlyInBlocksFromExtendedTemplateInIncludedTemplate(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerForTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerForTagTest.java new file mode 100644 index 000000000..ed256fda3 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerForTagTest.java @@ -0,0 +1,261 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedNodeInterpreter; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.ForTagTest; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.List; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerForTagTest extends ForTagTest { + + private static final long MAX_OUTPUT_SIZE = 5000L; + private ExpectedNodeInterpreter expectedNodeInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides + .newBuilder() + .withUsePyishObjectMapper(true) + .withKeepNullableLoopValues(true) + .build() + ) + .build() + ); + tag = new EagerForTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + expectedNodeInterpreter = + new ExpectedNodeInterpreter(interpreter, tag, "tags/eager/fortag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itRegistersDeferredToken() { + expectedNodeInterpreter.assertExpectedOutput("registers-eager-token"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()).contains("deferred"); + } + + @Test + public void itHandlesMultipleLoopVars() { + expectedNodeInterpreter.assertExpectedOutput("handles-multiple-loop-vars"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()).contains("deferred"); + } + + @Test + public void itHandlesNestedDeferredForLoop() { + context.put("food_types", ImmutableList.of("sandwich", "salad", "smoothie")); + expectedNodeInterpreter.assertExpectedOutput("handles-nested-deferred-for-loop"); + } + + @Test + public void itLimitsLength() { + String out = interpreter.render( + String.format( + "{%% for item in (range(1000, %s)) + deferred %%}{%% endfor %%}", + MAX_OUTPUT_SIZE + ) + ); + assertThat(interpreter.getContext().getDeferredTokens()).hasSize(1); + } + + @Test + public void itUsesDeferredExecutionModeWhenChildrenAreLarge() { + assertThat( + interpreter.render( + String.format( + "{%% for item in range(%d) %%}1234567890{%% endfor %%}", + MAX_OUTPUT_SIZE / 10 - 1 + ) + ) + ) + .hasSize((int) MAX_OUTPUT_SIZE - 10); + assertThat(interpreter.getContext().getDeferredTokens()).isEmpty(); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + assertThat(interpreter.getErrors()).isEmpty(); + String tooBigInput = String.format( + "{%% for item in range(%d) %%}1234567890{%% endfor %%}", + MAX_OUTPUT_SIZE / 10 + 1 + ); + assertThat(interpreter.render(tooBigInput)).isEqualTo(tooBigInput); + assertThat(interpreter.getContext().getDeferredTokens()).hasSize(1); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDoesntAllowChangesInDeferredFor() { + String result = interpreter.render( + "{% set foo = [0] -%}\n" + + "{%- for i in range(0, deferred) %}\n" + + "{{ 'bar' }}{{ foo }}\n" + + "{% do foo.append(1) %}\n" + + "{% endfor %}\n" + + "{{ foo }}" + ); + assertThat(result) + .isEqualTo( + "{% set foo = [0] %}{% for i in range(0, deferred) %}\n" + + "bar{{ foo }}\n" + + "{% do foo.append(1) %}\n" + + "{% endfor %}\n" + + "{{ foo }}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).hasSize(0); + } + + @Test + public void itDefersVariablesThatLaterGetDeferred() { + String result = interpreter.render( + "{%- for i in range(0, deferred) %}\n" + + "{{ foo ~ (1 + 2) }}\n" + + "{% set foo = i %}\n" + + "{% endfor %}" + ); + assertThat(result) + .isEqualTo( + "{% for i in range(0, deferred) %}\n" + + "{{ foo ~ 3 }}\n" + + "{% set foo = i %}\n" + + "{% endfor %}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).hasSize(0); + } + + @Test + public void itDoesntAllowChangesInDeferredForWithSameHashCode() { + // Map with {'a':'a'} has the same hashcode as {'b':'b'} so we must differentiate + String result = interpreter.render( + "{% set foo = {'a': 'a'} -%}\n" + + "{%- for i in range(0, deferred) %}\n" + + "{{ 'bar' }}{{ foo }}\n" + + "{% do foo.clear() %}\n" + + "{% do foo.update({'b': 'b'}) %}\n" + + "{% endfor %}\n" + + "{{ foo }}" + ); + assertThat(result) + .isEqualTo( + "{% set foo = {'a': 'a'} %}{% for i in range(0, deferred) %}\n" + + "bar{{ foo }}\n" + + "{% do foo.clear() %}\n" + + "{% do foo.update({'b': 'b'}) %}\n" + + "{% endfor %}\n" + + "{{ foo }}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).hasSize(0); + } + + @Test + public void itAllowsChangesInDeferredForToken() { + String output = interpreter.render( + "{% set foo = [0] %}\n" + + "{% for i in range(foo.append(1) ? 0 : 1, deferred) %}\n" + + "{{ i }}\n" + + "{% endfor %}\n" + + "{{ foo }}" + ); + assertThat(output.trim()) + .isEqualTo( + "{% for i in range(0, deferred) %}\n" + "{{ i }}\n" + "{% endfor %}\n" + "[0, 1]" + ); + } + + @Test + public void itDefersLoopVariable() { + String output = interpreter.render( + "{% for i in range(0, deferred) %}\n" + + "{{ loop.index }}\n" + + "{% endfor %}\n" + + "{% for i in range(0, 2) -%}\n" + + "{{ loop.index }}\n" + + "{% endfor %}" + ); + assertThat(output.trim()) + .isEqualTo( + "{% for i in range(0, deferred) %}\n" + + "{{ loop.index }}\n" + + "{% endfor %}\n" + + "1\n" + + "2" + ); + } + + @Test + public void itCanNowHandleModificationInPartiallyDeferredLoop() { + interpreter.getContext().registerTag(new EagerDoTag()); + interpreter.getContext().registerTag(new EagerIfTag()); + interpreter.getContext().registerTag(new EagerSetTag()); + + String input = + "{% set my_list = [] %}" + + "{% for i in range(401) %}" + + "{% do my_list.append(i) %}" + + "{% endfor %}" + + "{% for i in my_list.append(-1) ? [0, 1] : [0] %}" + + "{% for j in deferred %}" + + "{% if loop.first %}" + + "{% do my_list.append(i) %}" + + "{% endif %}" + + "{% endfor %}" + + "{% endfor %}" + + "{{ my_list }}"; + String initialResult = interpreter.render(input); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + interpreter.getContext().put("deferred", ImmutableList.of(1, 2)); + interpreter.render(initialResult); + assertThat(interpreter.getContext().get("my_list")).isInstanceOf(List.class); + assertThat((List) interpreter.getContext().get("my_list")) + .as( + "Appends 401 numbers and then appends '-1', running the 'i' loop twice," + + "which runs the 'j' loop, the first time appending the value of 'i', which will be '0', then '1'" + ) + .hasSize(404) + .containsSequence(400L, -1L, 0L, 1L); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + } + + public static boolean inForLoop() { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + return interpreter.getContext().isInForLoop(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTagTest.java new file mode 100644 index 000000000..dde89ecd7 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerFromTagTest.java @@ -0,0 +1,126 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.FromTag; +import com.hubspot.jinjava.lib.tag.FromTagTest; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerFromTagTest extends FromTagTest { + + @Before + public void eagerSetup() { + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("tags/macrotag/%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + context.put("padding", 42); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + Tag tag = EagerTagFactory + .getEagerTagDecorator(new FromTag()) + .orElseThrow(RuntimeException::new); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itDefersWhenPathIsDeferred() { + String input = "{% from deferred import foo %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo("{% set current_path = '' %}" + input); + assertThat(interpreter.getContext().getGlobalMacro("foo")).isNotNull(); + assertThat(interpreter.getContext().getGlobalMacro("foo").isDeferred()).isTrue(); + assertThat(interpreter.getContext().getDeferredTokens()) + .isNotEmpty() + .anySatisfy(deferredToken -> { + assertThat(deferredToken.getToken().getImage()) + .isEqualTo("{% from deferred import foo %}"); + assertThat(deferredToken.getSetDeferredWords()).containsExactly("foo"); + assertThat(deferredToken.getUsedDeferredWords()) + .containsExactlyInAnyOrder("deferred", "foo"); + }); + } + + @Test + public void itDefersWhenPathIsDeferredWithAlias() { + String input = "{% from deferred import foo as new_foo %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo("{% set current_path = '' %}" + input); + assertThat(interpreter.getContext().getGlobalMacro("new_foo")).isNotNull(); + assertThat(interpreter.getContext().getGlobalMacro("new_foo").isDeferred()).isTrue(); + assertThat(interpreter.getContext().getDeferredTokens()) + .isNotEmpty() + .anySatisfy(deferredToken -> { + assertThat(deferredToken.getToken().getImage()) + .isEqualTo("{% from deferred import foo as new_foo %}"); + assertThat(deferredToken.getSetDeferredWords()).containsExactly("new_foo"); + assertThat(deferredToken.getUsedDeferredWords()) + .containsExactlyInAnyOrder("deferred", "foo"); + }); + assertThat(interpreter.getContext().get("new_foo")).isInstanceOf(DeferredValue.class); + } + + @Test + public void itReconstructsCurrentPath() { + interpreter.getContext().put(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, "bar"); + + String input = "{% from deferred import foo %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo("{% set current_path = 'bar' %}" + input); + assertThat(interpreter.getContext().getGlobalMacro("foo")).isNotNull(); + assertThat(interpreter.getContext().getGlobalMacro("foo").isDeferred()).isTrue(); + } + + @Test + @Ignore + @Override + public void itDefersImport() {} +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTagTest.java new file mode 100644 index 000000000..2c415f550 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIfTagTest.java @@ -0,0 +1,105 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedNodeInterpreter; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.IfTagTest; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerIfTagTest extends IfTagTest { + + private ExpectedNodeInterpreter expectedNodeInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + tag = new EagerIfTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + expectedNodeInterpreter = + new ExpectedNodeInterpreter(interpreter, tag, "tags/eager/iftag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itHandlesDeferredInRegular() { + context.put("foo", true); + expectedNodeInterpreter.assertExpectedOutput("handles-deferred-in-regular"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeEagerTagToken).isEmpty(); + } + + @Test + public void itHandlesDeferredInEager() { + context.put("foo", 1); + expectedNodeInterpreter.assertExpectedOutput("handles-deferred-in-eager"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeEagerTagToken).isPresent(); + assertThat(maybeEagerTagToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeEagerTagToken.get().getUsedDeferredWords()) + .containsExactly("deferred"); + } + + @Test + public void itHandlesOnlyDeferredElif() { + context.put("foo", 1); + expectedNodeInterpreter.assertExpectedOutput("handles-only-deferred-elif"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals("elif")) + .findAny(); + assertThat(maybeEagerTagToken).isPresent(); + assertThat(maybeEagerTagToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeEagerTagToken.get().getUsedDeferredWords()) + .containsExactly("deferred"); + } + + @Test + public void itRemovesImpossibleIfBlocks() { + context.put("foo", 1); + expectedNodeInterpreter.assertExpectedOutput("removes-impossible-if-blocks"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeEagerTagToken).isPresent(); + assertThat(maybeEagerTagToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeEagerTagToken.get().getUsedDeferredWords()) + .containsExactly("deferred"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTagTest.java new file mode 100644 index 000000000..58de257ed --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerImportTagTest.java @@ -0,0 +1,814 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.base.Strings; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.lib.tag.ImportTag; +import com.hubspot.jinjava.lib.tag.ImportTagTest; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.lib.tag.eager.importing.AliasedEagerImportingStrategy; +import com.hubspot.jinjava.lib.tag.eager.importing.EagerImportingStrategy; +import com.hubspot.jinjava.lib.tag.eager.importing.EagerImportingStrategyFactory; +import com.hubspot.jinjava.lib.tag.eager.importing.FlatEagerImportingStrategy; +import com.hubspot.jinjava.lib.tag.eager.importing.ImportingData; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.testobjects.EagerImportTagTestObjects; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerImportTagTest extends ImportTagTest { + + private static final String CONTEXT_VAR = "context_var"; + private static final String TEMPLATE_FILE = "template.jinja"; + + private TagToken tagToken; + + @Before + public void eagerSetup() throws Exception { + context.put("padding", 42); + context.registerFilter(new EagerImportTagTestObjects.PrintPathFilter()); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .withEnableRecursiveMacroCalls(true) + .withMaxMacroRecursionDepth(10) + .build() + ); + Tag tag = EagerTagFactory + .getEagerTagDecorator(new ImportTag()) + .orElseThrow(RuntimeException::new); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + JinjavaInterpreter.pushCurrent(interpreter); + tagToken = + new TagToken( + String.format("{%% import foo as %s %%}", CONTEXT_VAR), + 0, + 0, + new DefaultTokenScannerSymbols() + ); + } + + private AliasedEagerImportingStrategy getAliasedStrategy( + String alias, + JinjavaInterpreter parentInterpreter + ) { + ImportingData importingData = EagerImportingStrategyFactory.getImportingData( + tagToken, + parentInterpreter + ); + + return new AliasedEagerImportingStrategy(importingData, alias); + } + + private FlatEagerImportingStrategy getFlatStrategy( + JinjavaInterpreter parentInterpreter + ) { + ImportingData importingData = EagerImportingStrategyFactory.getImportingData( + tagToken, + parentInterpreter + ); + + return new FlatEagerImportingStrategy(importingData); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itRemovesKeysFromChildBindings() { + JinjavaInterpreter child = getChildInterpreter(interpreter, CONTEXT_VAR); + Map childBindings = child.getContext().getSessionBindings(); + assertThat(childBindings.get(Context.IMPORT_RESOURCE_ALIAS_KEY)) + .isEqualTo(CONTEXT_VAR); + getAliasedStrategy(CONTEXT_VAR, interpreter).integrateChild(child); + assertThat(interpreter.getContext().get(CONTEXT_VAR)).isInstanceOf(Map.class); + assertThat(((Map) interpreter.getContext().get(CONTEXT_VAR)).keySet()) + .doesNotContain(Context.IMPORT_RESOURCE_ALIAS_KEY); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayer() { + JinjavaInterpreter child = getChildInterpreter(interpreter, ""); + JinjavaInterpreter child2 = getChildInterpreter(child, ""); + child2.getContext().put("foo", "foo val"); + child.getContext().put("bar", "bar val"); + getFlatStrategy(child).integrateChild(child2); + getFlatStrategy(interpreter).integrateChild(child); + assertThat(interpreter.getContext().get("foo")).isEqualTo("foo val"); + assertThat(interpreter.getContext().get("bar")).isEqualTo("bar val"); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayerAliased() { + String child2Alias = "double_child"; + JinjavaInterpreter child = getChildInterpreter(interpreter, CONTEXT_VAR); + JinjavaInterpreter child2 = getChildInterpreter(child, child2Alias); + + child2.render("{% set foo = 'foo val' %}"); + child.render("{% set bar = 'bar val' %}"); + + getAliasedStrategy(child2Alias, child).integrateChild(child2); + getAliasedStrategy(CONTEXT_VAR, interpreter).integrateChild(child); + + assertThat(interpreter.getContext().get(CONTEXT_VAR)).isInstanceOf(Map.class); + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get(child2Alias) + ) + .isInstanceOf(Map.class); + assertThat( + ((Map) ((Map) interpreter + .getContext() + .get(CONTEXT_VAR)).get(child2Alias)).get("foo") + ) + .isEqualTo("foo val"); + + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get("bar") + ) + .isEqualTo("bar val"); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayerAliasedAndDeferred() { + setupResourceLocator(); + String child2Alias = "double_child"; + RenderResult result = jinjava.renderForResult( + "{% import 'layer-one.jinja' as context_var %}", + new HashMap<>() + ); + + assertThat(result.getContext().get(CONTEXT_VAR)).isInstanceOf(DeferredValue.class); + assertThat( + ((Map) ((DeferredValue) result + .getContext() + .get(CONTEXT_VAR)).getOriginalValue()).get(child2Alias) + ) + .isInstanceOf(DeferredValue.class); + assertThat( + (((Map) ((DeferredValue) (((Map) ((DeferredValue) result + .getContext() + .get(CONTEXT_VAR)).getOriginalValue())).get( + child2Alias + )).getOriginalValue()).get("foo")) + ) + .isEqualTo("foo val"); + + assertThat( + ((Map) ((DeferredValue) result + .getContext() + .get(CONTEXT_VAR)).getOriginalValue()).get("bar") + ) + .isEqualTo("bar val"); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayerDeferred() { + JinjavaInterpreter child = getChildInterpreter(interpreter, ""); + JinjavaInterpreter child2 = getChildInterpreter(child, ""); + child2.getContext().put("foo", DeferredValue.instance("foo val")); + child.getContext().put("bar", DeferredValue.instance("bar val")); + + getFlatStrategy(child).integrateChild(child2); + getFlatStrategy(interpreter).integrateChild(child); + assertThat(interpreter.getContext().get("foo")).isInstanceOf(DeferredValue.class); + assertThat( + (((DeferredValue) (interpreter.getContext().get("foo"))).getOriginalValue()) + ) + .isEqualTo("foo val"); + + assertThat(interpreter.getContext().get("bar")).isInstanceOf(DeferredValue.class); + assertThat( + (((DeferredValue) (interpreter.getContext().get("bar"))).getOriginalValue()) + ) + .isEqualTo("bar val"); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayerSomeAliased() { + String child3Alias = "triple_child"; + JinjavaInterpreter child = getChildInterpreter(interpreter, CONTEXT_VAR); + JinjavaInterpreter child2 = getChildInterpreter(child, ""); + JinjavaInterpreter child3 = getChildInterpreter(child2, child3Alias); + + child2.render("{% set foo = 'foo val' %}"); + child.render("{% set bar = 'bar val' %}"); + child3.render("{% set foobar = 'foobar val' %}"); + + getAliasedStrategy(child3Alias, child2).integrateChild(child3); + getFlatStrategy(child).integrateChild(child2); + getAliasedStrategy(CONTEXT_VAR, interpreter).integrateChild(child); + + assertThat(interpreter.getContext().get(CONTEXT_VAR)).isInstanceOf(Map.class); + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get(child3Alias) + ) + .isInstanceOf(Map.class); + assertThat( + ((Map) ((Map) interpreter + .getContext() + .get(CONTEXT_VAR)).get(child3Alias)).get("foobar") + ) + .isEqualTo("foobar val"); + + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get("bar") + ) + .isEqualTo("bar val"); + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get("foo") + ) + .isEqualTo("foo val"); + } + + @Test + @SuppressWarnings("unchecked") + public void itHandlesMultiLayerAliasedAndParallel() { + String child2Alias = "double_child"; + String child2BAlias = "double_child_b"; + + JinjavaInterpreter child = getChildInterpreter(interpreter, CONTEXT_VAR); + JinjavaInterpreter child2 = getChildInterpreter(child, child2Alias); + JinjavaInterpreter child2B = getChildInterpreter(child, child2BAlias); + + child2.render("{% set foo = 'foo val' %}"); + child.render("{% set bar = 'bar val' %}"); + child2B.render("{% set foo_b = 'foo_b val' %}"); + + getAliasedStrategy(child2Alias, child).integrateChild(child2); + getAliasedStrategy(child2BAlias, child).integrateChild(child2B); + getAliasedStrategy(CONTEXT_VAR, interpreter).integrateChild(child); + + assertThat(interpreter.getContext().get(CONTEXT_VAR)).isInstanceOf(Map.class); + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get(child2Alias) + ) + .isInstanceOf(Map.class); + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get(child2BAlias) + ) + .isInstanceOf(Map.class); + assertThat( + ((Map) ((Map) interpreter + .getContext() + .get(CONTEXT_VAR)).get(child2Alias)).get("foo") + ) + .isEqualTo("foo val"); + assertThat( + ((Map) ((Map) interpreter + .getContext() + .get(CONTEXT_VAR)).get(child2BAlias)).get("foo_b") + ) + .isEqualTo("foo_b val"); + + assertThat( + ((Map) interpreter.getContext().get(CONTEXT_VAR)).get("bar") + ) + .isEqualTo("bar val"); + } + + @Test + public void itHandlesTripleLayer() { + setupResourceLocator(); + context.put("a_val", "a"); + context.put("b_val", "b"); + context.put("c_val", "c"); + interpreter.render("{% import 'import-tree-c.jinja' as c %}"); + assertThat(interpreter.render("{{ c.b.a.foo_a }}")).isEqualTo("a"); + assertThat(interpreter.render("{{ c.b.foo_b }}")).isEqualTo("ba"); + assertThat(interpreter.render("{{ c.foo_c }}")).isEqualTo("cbaa"); + } + + @Test + public void itDefersTripleLayer() { + setupResourceLocator(); + context.put("a_val", DeferredValue.instance("a")); + + context.put("b_val", "b"); + context.put("c_val", "c"); + String result = interpreter.render( + "{% import 'import-tree-c.jinja' as c %}{{ c.b|dictsort(false, 'key') }}{{ c.foo_b }}{{ c.import_resource_path }}" + ); + assertThat(interpreter.render("{{ c.b.a.foo_a }}")).isEqualTo("{{ c.b.a.foo_a }}"); + assertThat(interpreter.render("{{ c.b.foo_b }}")).isEqualTo("{{ c.b.foo_b }}"); + assertThat(interpreter.render("{{ c.foo_c }}")).isEqualTo("{{ c.foo_c }}"); + removeDeferredContextKeys(); + context.put("a_val", "a"); + // There are some extras due to deferred values copying up the context stack. + context.getDeferredTokens().clear(); + assertThat(interpreter.render(result).trim()) + .isEqualTo( + interpreter.render( + "{% import 'import-tree-c.jinja' as c %}{{ c.b|dictsort(false, 'key') }}{{ c.foo_b }}{{ c.import_resource_path }}" + ) + ); + } + + @Test + public void itHandlesQuadLayer() { + setupResourceLocator(); + context.put("a_val", "a"); + context.put("b_val", "b"); + context.put("c_val", "c"); + interpreter.render("{% import 'import-tree-d.jinja' as d %}"); + assertThat(interpreter.render("{{ d.foo_d }}")).isEqualTo("cbaabaa"); + assertThat(interpreter.render("{{ d.resolvable }}")).isEqualTo("12345"); + assertThat(interpreter.render("{{ d.bar }}")).isEqualTo("cbaabaaba"); + } + + @Test + public void itDefersQuadLayer() { + setupResourceLocator(); + context.put("a_val", DeferredValue.instance("a")); + context.put("b_val", "b"); + context.put("c_val", "c"); + String result = interpreter.render( + "{% import 'import-tree-d.jinja' as d %}{{ d.resolvable }} {{ d.bar }}" + ); + removeDeferredContextKeys(); + + context.put("a_val", "a"); + context.getDeferredTokens().clear(); + assertThat(interpreter.render(result).trim()).isEqualTo("12345 cbaabaaba"); + } + + @Test + public void itHandlesQuadLayerInDeferredIf() { + setupResourceLocator(); + context.put("a_val", "a"); + context.put("b_val", "b"); + String result = interpreter.render( + "{% if deferred %}{% import 'import-tree-b.jinja' as b %}{% endif %}" + ); + assertThat(result) + .isEqualTo( + "{% if deferred %}{% do %}{% set __temp_meta_current_path_930119534__,current_path = current_path,'import-tree-b.jinja' %}{% set __temp_meta_import_alias_98__ = {} %}{% for __ignored__ in [0] %}{% do %}{% set __temp_meta_current_path_58714367__,current_path = current_path,'import-tree-a.jinja' %}{% set __temp_meta_import_alias_95701__ = {} %}{% for __ignored__ in [0] %}{% set something = 'somn' %}{% do __temp_meta_import_alias_95701__.update({'something': something}) %}\n" + + "{% set foo_a = 'a' %}{% do __temp_meta_import_alias_95701__.update({'foo_a': foo_a}) %}\n" + + "{% do __temp_meta_import_alias_95701__.update({'foo_a': 'a','import_resource_path': 'import-tree-a.jinja','something': 'somn'}) %}{% endfor %}{% set a = __temp_meta_import_alias_95701__ %}{% set current_path,__temp_meta_current_path_58714367__ = __temp_meta_current_path_58714367__,null %}{% enddo %}\n" + + "{% set foo_b = 'b' + a.foo_a %}{% do __temp_meta_import_alias_98__.update({'foo_b': foo_b}) %}\n" + + "{% do __temp_meta_import_alias_98__.update({'a': a,'foo_b': foo_b,'import_resource_path': 'import-tree-b.jinja'}) %}{% endfor %}{% set b = __temp_meta_import_alias_98__ %}{% set current_path,__temp_meta_current_path_930119534__ = __temp_meta_current_path_930119534__,null %}{% enddo %}{% endif %}" + ); + + removeDeferredContextKeys(); + context.put("deferred", true); + + interpreter.render(result); + assertThat(interpreter.render("{{ b.foo_b }}")).isEqualTo("ba"); + assertThat(interpreter.render("{{ b.a.foo_a }}")).isEqualTo("a"); + } + + @Test + public void itCorrectlySetsAliasedPath() { + setupResourceLocator(); + context.put("foo", "foo"); + String result = interpreter.render( + "{% import 'import-macro.jinja' as m %}{{ m.print_path_macro(foo) }}" + ); + assertThat(result.trim()).isEqualTo("import-macro.jinja\nfoo"); + } + + @Test + public void itCorrectlySetsPath() { + setupResourceLocator(); + context.put("foo", "foo"); + String result = interpreter.render( + "{% import 'import-macro.jinja' %}{{ print_path_macro(foo) }}" + ); + assertThat(result.trim()).isEqualTo("import-macro.jinja\nfoo"); + } + + @Test + public void itCorrectlySetsAliasedPathForSecondPass() { + setupResourceLocator(); + context.put("foo", DeferredValue.instance()); + String firstPassResult = interpreter.render( + "{% import 'import-macro.jinja' as m %}{{ m.print_path_macro(foo) }}" + ); + assertThat(firstPassResult) + .isEqualTo( + "{% set deferred_import_resource_path = 'import-macro.jinja' %}{% macro m.print_path_macro(var) %}\n" + + "{{ filter:print_path.filter(var, ____int3rpr3t3r____) }}\n" + + "{{ var }}\n" + + "{% endmacro %}{% set deferred_import_resource_path = null %}{{ m.print_path_macro(foo) }}" + ); + context.put("foo", "foo"); + assertThat(interpreter.render(firstPassResult).trim()) + .isEqualTo("import-macro.jinja\nfoo"); + } + + @Test + public void itCorrectlySetsPathForSecondPass() { + setupResourceLocator(); + context.put("foo", DeferredValue.instance()); + String firstPassResult = interpreter.render( + "{% import 'import-macro.jinja' %}{{ print_path_macro(foo) }}" + ); + assertThat(firstPassResult) + .isEqualTo( + "{% set deferred_import_resource_path = 'import-macro.jinja' %}{% macro print_path_macro(var) %}\n" + + "{{ filter:print_path.filter(var, ____int3rpr3t3r____) }}\n" + + "{{ var }}\n" + + "{% endmacro %}{% set deferred_import_resource_path = null %}{{ print_path_macro(foo) }}" + ); + context.put("foo", "foo"); + assertThat(interpreter.render(firstPassResult).trim()) + .isEqualTo("import-macro.jinja\nfoo"); + } + + @Test + public void itCorrectlySetsNestedPathsForSecondPass() { + setupResourceLocator(); + context.put("foo", DeferredValue.instance()); + String firstPassResult = interpreter.render( + "{% import 'double-import-macro.jinja' %}{{ print_path_macro2(foo) }}" + ); + assertThat(firstPassResult) + .isEqualTo( + "{% set deferred_import_resource_path = 'double-import-macro.jinja' %}{% macro print_path_macro2(var) %}{{ filter:print_path.filter(var, ____int3rpr3t3r____) }}\n" + + "{% set deferred_import_resource_path = 'import-macro.jinja' %}{% macro print_path_macro(var) %}\n" + + "{{ filter:print_path.filter(var, ____int3rpr3t3r____) }}\n" + + "{{ var }}\n" + + "{% endmacro %}{% set deferred_import_resource_path = 'double-import-macro.jinja' %}{{ print_path_macro(var) }}{% endmacro %}{% set deferred_import_resource_path = null %}{{ print_path_macro2(foo) }}" + ); + context.put("foo", "foo"); + context.put(Context.GLOBAL_MACROS_SCOPE_KEY, null); + assertThat(interpreter.render(firstPassResult).trim()) + .isEqualTo("double-import-macro.jinja\n\nimport-macro.jinja\nfoo"); + } + + @Test + public void itImportsDoublyNamed() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'variables.jinja' as foo %}{{ foo.foo['foo'].bar }}" + ); + assertThat(result).isEqualTo("here"); + } + + @Test + public void itKeepsImportAliasesInsideOwnScope() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'printer-a.jinja' as printer %}{% import 'intermediate-b.jinja' as inter %}" + + "{{ printer.print() }}-{{ inter.print() }}" + ); + assertThat(result.trim()).isEqualTo("A_A_A-B_inter_B"); + } + + @Test + public void itKeepsImportAliasVariablesInsideOwnScope() { + setupResourceLocator(); + String result = interpreter.render( + "{% set printer = {'key': 'val'} %}{% import 'intermediate-b.jinja' as inter %}" + + "{{ printer }}-{{ inter.print() }}" + ); + assertThat(result.trim()).isEqualTo("{'key': 'val'}-B_inter_B"); + } + + @Test + public void itKeepsDeferredImportAliasesInsideOwnScope() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'printer-a.jinja' as printer %}{% import 'intermediate-b.jinja' as inter %}" + + "{{ printer.print(deferred) }}-{{ inter.print(deferred) }}" + ); + context.put("deferred", "resolved"); + assertThat(interpreter.render(result)).isEqualTo("A_resolved_A-B_resolved_B"); + } + + @Test + public void itKeepsDeferredImportAliasesInsideOwnScopeInSet() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'printer-a.jinja' as printer %}{% import 'intermediate-b.jinja' as inter %}" + + "{% set foo = printer.print(deferred) %}{% set bar = inter.print(deferred) %}" + + "{{ foo }}-{{ bar }}" + ); + context.put("deferred", "resolved"); + assertThat(interpreter.render(result)).isEqualTo("A_resolved_A-B_resolved_B"); + } + + @Test + public void itDefersWhenPathIsDeferred() { + String input = "{% import deferred as foo %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo("{% set current_path = '' %}" + input); + assertThat(interpreter.getContext().get("foo")) + .isNotNull() + .isInstanceOf(DeferredValue.class); + } + + @Test + public void itReconstructsCurrentPath() { + interpreter.getContext().put(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY, "bar"); + String input = "{% import deferred as foo %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo("{% set current_path = 'bar' %}" + input); + assertThat(interpreter.getContext().get("foo")) + .isNotNull() + .isInstanceOf(DeferredValue.class); + } + + @Test + public void itDefersNodeWhenNoImportAlias() { + String input = "{% import deferred %}"; + String output = interpreter.render(input); + assertThat(output).isEqualTo(input); + assertThat(interpreter.getContext().getDeferredNodes()).hasSize(1); + } + + @Test + public void itHandlesVarFromImportedMacro() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'import-macro-and-var.jinja' -%}\n" + + "{{ adjust('a') }}\n" + + "{{ adjust('b') }}\n" + + "c{{ var }}" + ); + assertThat(result.trim()) + .isEqualTo( + "{% set var = [] %}" + + "{% set __macro_adjust_108896029_temp_variable_0__ %}" + + "{% do var.append('a' ~ deferred) %}" + + "a" + + "{% endset %}" + + "{{ __macro_adjust_108896029_temp_variable_0__ }}\n" + + "{% set __macro_adjust_108896029_temp_variable_1__ %}" + + "{% do var.append('b' ~ deferred) %}" + + "b" + + "{% endset %}" + + "{{ __macro_adjust_108896029_temp_variable_1__ }}\n" + + "c{{ var }}" + ); + context.put("deferred", "resolved"); + assertThat(interpreter.render(result).trim()) + .isEqualTo("a\n" + "b\n" + "c['aresolved', 'bresolved']"); + } + + @Test + public void itPutsDeferredInOtherSpotValuesOnContext() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'import-macro-applies-val.jinja' -%}\n" + + "{% set val = deferred -%}\n" + + "{{ apply('foo') }}\n" + + "{{ val }}" + ); + assertThat(result.trim()).isEqualTo("{% set val = deferred %}5foo\n{{ val }}"); + context.put("deferred", "resolved"); + assertThat(interpreter.render(result).trim()).isEqualTo("5foo\nresolved"); + } + + @Test + public void itDoesNotSilentlyOverrideMacro() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'macro-a.jinja' as macros %}\n" + + "{{ macros.doer() }}\n" + + "{% if deferred %}\n" + + " {% import 'macro-b.jinja' as macros %}\n" + + "{% endif %}\n" + + "{{ macros.doer() }}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itDoesNotSilentlyOverrideMacroWithoutAlias() { + setupResourceLocator(); + String result = interpreter.render( + "{% import 'macro-a.jinja' %}\n" + + "{{ doer() }}\n" + + "{% if deferred %}\n" + + " {% import 'macro-b.jinja' %}\n" + + "{% endif %}\n" + + "{{ doer() }}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } + + @Test + public void itDoesNotSilentlyOverrideVariable() { + setupResourceLocator(); + String result = interpreter + .render( + "{% import 'var-a.jinja' as vars %}" + + "{{ vars.foo }}" + + "{% if deferred %}" + + " {%- import 'var-b.jinja' as vars %}" + + "{% endif %}" + + "{{ vars.foo }}" + ) + .trim(); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + assertThat(result) + .isEqualTo( + "a" + + "{% set vars = {'foo': 'a', 'import_resource_path': 'var-a.jinja'} %}" + + "{% if deferred %}" + + "{% do %}" + + "{% set __temp_meta_current_path_299252430__,current_path = current_path,'var-b.jinja' %}" + + "{% set __temp_meta_import_alias_3612204__ = {} %}" + + "{% for __ignored__ in [0] %}" + + "{% set foo = 'b' %}" + + "{% do __temp_meta_import_alias_3612204__.update({'foo': foo}) %}\n" + + "{% do __temp_meta_import_alias_3612204__.update({'foo': 'b','import_resource_path': 'var-b.jinja'}) %}" + + "{% endfor %}" + + "{% set vars = __temp_meta_import_alias_3612204__ %}" + + "{% set current_path,__temp_meta_current_path_299252430__ = __temp_meta_current_path_299252430__,null %}" + + "{% enddo %}" + + "{% endif %}" + + "{{ vars.foo }}" + ); + interpreter.getContext().put("deferred", "resolved"); + context.getDeferredTokens().clear(); + assertThat(interpreter.render(result)).isEqualTo("ab"); + } + + @Test + public void itDoesNotSilentlyOverrideVariableWithoutAlias() { + setupResourceLocator(); + String result = interpreter + .render( + "{% import 'var-a.jinja' %}" + + "{{ foo }}" + + "{% if deferred %}" + + " {%- import 'var-b.jinja' %}" + + "{% endif %}" + + "{{ foo }}" + ) + .trim(); + assertThat(interpreter.getContext().getDeferredNodes()).isEmpty(); + assertThat(result) + .isEqualTo( + "a" + + "{% set foo = 'a' %}" + + "{% if deferred %}" + + "{% do %}" + + "{% set __temp_meta_current_path_299252430__,current_path = current_path,'var-b.jinja' %}" + + "{% set foo = 'b' %}\n" + + "{% set current_path,__temp_meta_current_path_299252430__ = __temp_meta_current_path_299252430__,null %}" + + "{% enddo %}" + + "{% endif %}" + + "{{ foo }}" + ); + + interpreter.getContext().put("deferred", "resolved"); + context.getDeferredTokens().clear(); + assertThat(interpreter.render(result)).isEqualTo("ab"); + } + + @Test + public void itDoesNotDeferImportedVariablesWhenNotInDeferredExecutionMode() { + setupResourceLocator(); + String result = interpreter + .render("{% import 'set-two-variables.jinja' %}" + "{{ foo }} {{ bar }}") + .trim(); + assertThat(result) + .isEqualTo( + "{% do %}" + + "{% set __temp_meta_current_path_549676938__,current_path = current_path,'set-two-variables.jinja' %}" + + "{% set foo = deferred %}\n" + + "\n" + + "{% set current_path,__temp_meta_current_path_549676938__ = __temp_meta_current_path_549676938__,null %}" + + "{% enddo %}" + + "{{ foo }} bar" + ); + } + + private JinjavaInterpreter getChildInterpreter( + JinjavaInterpreter interpreter, + String alias + ) { + JinjavaInterpreter child = interpreter + .getConfig() + .getInterpreterFactory() + .newInstance(interpreter); + child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, TEMPLATE_FILE); + EagerImportingStrategy eagerImportingStrategy; + if (Strings.isNullOrEmpty(alias)) { + eagerImportingStrategy = getFlatStrategy(interpreter); + } else { + eagerImportingStrategy = getAliasedStrategy(alias, interpreter); + } + eagerImportingStrategy.setup(child); + return child; + } + + private void removeDeferredContextKeys() { + context + .entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof DeferredValue) + .map(Entry::getKey) + .collect(Collectors.toSet()) + .forEach(context::remove); + } + + private void setupResourceLocator() { + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("tags/eager/importtag/%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + } + + @Test + @Ignore + @Override + public void itReconstructsDeferredImportTag() {} + + @Test + @Ignore + @Override + public void itDoesNotRenderTagsDependingOnDeferredImport() {} + + @Test + @Ignore + @Override + public void itAddsAllDeferredNodesOfImport() {} + + @Test + @Ignore + @Override + public void itAddsAllDeferredNodesOfGlobalImport() {} + + @Test + @Ignore + @Override + public void itSetsErrorLineNumbersCorrectly() {} + + @Test + @Ignore + @Override + public void itSetsErrorLineNumbersCorrectlyForImportedMacros() {} + + @Test + @Ignore + @Override + public void itDefersImportedVariableKey() {} + + @Test + @Ignore + @Override + public void itDoesNotRenderTagsDependingOnDeferredGlobalImport() {} + + @Test + @Ignore + @Override + public void itSetsErrorLineNumbersCorrectlyThroughIncludeTag() {} +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTagTest.java new file mode 100644 index 000000000..afd93de07 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerIncludeTagTest.java @@ -0,0 +1,111 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedTemplateInterpreter; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.IncludeTagTest; +import com.hubspot.jinjava.loader.LocationResolver; +import com.hubspot.jinjava.loader.RelativePathResolver; +import com.hubspot.jinjava.loader.ResourceLocator; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerIncludeTagTest extends IncludeTagTest { + + private ExpectedTemplateInterpreter expectedTemplateInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + context.put("deferred", DeferredValue.instance()); + expectedTemplateInterpreter = + new ExpectedTemplateInterpreter(jinjava, interpreter, "tags/eager/includetag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + private void setupResourceLocator() { + jinjava.setResourceLocator( + new ResourceLocator() { + private RelativePathResolver relativePathResolver = new RelativePathResolver(); + + @Override + public String getString( + String fullName, + Charset encoding, + JinjavaInterpreter interpreter + ) throws IOException { + return Resources.toString( + Resources.getResource(String.format("tags/eager/includetag/%s", fullName)), + StandardCharsets.UTF_8 + ); + } + + @Override + public Optional getLocationResolver() { + return Optional.of(relativePathResolver); + } + } + ); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itIncludesDeferred() { + setupResourceLocator(); + expectedTemplateInterpreter.assertExpectedOutputNonIdempotent("includes-deferred"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("foo", "deferred"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getSetDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("foo"); + } + + @Override + @Test + @Ignore + public void itSetsErrorLineNumbersCorrectly() throws IOException { + super.itSetsErrorLineNumbersCorrectly(); + } + + @Override + @Test + @Ignore + public void itSetsErrorLineNumbersCorrectlyTwoLevelsDeep() throws IOException { + super.itSetsErrorLineNumbersCorrectlyTwoLevelsDeep(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagTest.java new file mode 100644 index 000000000..0e6830a4c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagTest.java @@ -0,0 +1,335 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedNodeInterpreter; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.lib.tag.SetTagTest; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.Namespace; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.HashMap; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +public class EagerSetTagTest extends SetTagTest { + + private static final long MAX_OUTPUT_SIZE = 500L; + private ExpectedNodeInterpreter expectedNodeInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + tag = new EagerSetTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + expectedNodeInterpreter = + new ExpectedNodeInterpreter(interpreter, tag, "tags/eager/settag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itEvaluatesExpression() { + context.put("bar", 3); + context.setDeferredExecutionMode(true); + expectedNodeInterpreter.assertExpectedOutput("evaluates-expression"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()).containsExactly("foo"); + } + + @Test + public void itPartiallyEvaluatesDeferredExpression() { + context.put("bar", 3); + context.setDeferredExecutionMode(true); + expectedNodeInterpreter.assertExpectedOutput( + "partially-evaluates-deferred-expression" + ); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()) + .containsExactlyInAnyOrder("foo"); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()) + .containsExactlyInAnyOrder("deferred", "range"); + } + + @Test + public void itHandlesMultipleVars() { + context.put("bar", 3); + context.put("baz", 6); + context.setDeferredExecutionMode(true); + expectedNodeInterpreter.assertExpectedOutput("handles-multiple-vars"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()) + .containsExactlyInAnyOrder("foo", "foobar"); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()) + .containsExactlyInAnyOrder("deferred", "range"); + } + + @Test + public void itDefersNamespaceRootAsUsedWordForDottedSet() { + context.put("ns", new Namespace()); + context.setDeferredExecutionMode(true); + interpreter.render("{% set ns.enabled = deferred %}"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()) + .containsExactly("ns.enabled"); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()).contains("ns"); + } + + @Test + public void itDefersNamespaceRootAsUsedWordForDottedBlockSet() { + context.put("ns", new Namespace()); + context.setDeferredExecutionMode(true); + interpreter.render("{% set ns.enabled %}{{ deferred }}{% endset %}"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getSetDeferredWords()) + .containsExactly("ns.enabled"); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()).contains("ns"); + } + + @Test + public void itDoesNotDeferNonNamespaceRootAsUsedWordForDottedSet() { + context.put("someMap", new HashMap()); + context.setDeferredExecutionMode(true); + interpreter.render("{% set someMap.field = deferred %}"); + Optional maybeDeferredToken = context + .getDeferredTokens() + .stream() + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeDeferredToken).isPresent(); + assertThat(maybeDeferredToken.get().getUsedDeferredWords()).doesNotContain("someMap"); + } + + @Test + public void itDoesNotThrowWhenNamespaceHoldsUnserializableValue() { + Namespace ns = new Namespace(); + ns.put("obj", new Object()); + context.put("ns", ns); + context.setDeferredExecutionMode(true); + interpreter.render("{% set ns.enabled = deferred %}"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itLimitsLength() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < MAX_OUTPUT_SIZE; i++) { + tooLong.append(i); + } + context.setDeferredExecutionMode(true); + interpreter.render(String.format("{%% set deferred = '%s' %%}", tooLong)); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getReason()) + .isEqualTo(ErrorReason.OUTPUT_TOO_BIG); + } + + @Test + public void itDefersBlock() { + String template = "{% set foo %}{{ 'i am' }}{{ deferred }}{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("{% set foo %}i am{{ deferred }}{% endset %}{{ foo }}"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getSetDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactly("foo"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("foo", "deferred"); + } + + @Test + public void itDefersBlockWithFilter() { + String template = + "{% set foo | int |add(deferred) %}{{ 1 + 1 }}{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result) + .isEqualTo( + "{% set foo = filter:add.filter(2, ____int3rpr3t3r____, deferred) %}{{ foo }}" + ); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getSetDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactly("foo"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("deferred", "foo", "add.filter"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredBases().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("deferred", "foo", "add"); + } + + @Test + public void itDefersDeferredBlockWithDeferredFilter() { + String template = + "{% set foo | add(deferred|int) %}{{ 1 + deferred }}{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result) + .isEqualTo( + "{% set foo %}{{ 1 + deferred }}{% endset %}{% set foo = filter:add.filter(foo, ____int3rpr3t3r____, filter:int.filter(deferred, ____int3rpr3t3r____)) %}{{ foo }}" + ); + context.remove("foo"); + context.put("deferred", 2); + assertThat(interpreter.render(result)).isEqualTo(interpreter.render(template)); // 1 + 2 + 2 = 5 + } + + @Test + public void itDefersInDeferredExecutionMode() { + context.setDeferredExecutionMode(true); + String template = "{% set foo %}{{ 'i am iron man' }}{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result).isEqualTo("{% set foo %}i am iron man{% endset %}i am iron man"); + } + + @Test + public void itDefersInDeferredExecutionModeWithFilter() { + context.setDeferredExecutionMode(true); + String template = "{% set foo | int | add(deferred) %}1{% endset %}{{ foo }}"; + final String result = interpreter.render(template); + + assertThat(result) + .isEqualTo( + "{% set foo %}1{% endset %}{% set foo = filter:add.filter(1, ____int3rpr3t3r____, deferred) %}{{ foo }}" + ); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getSetDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactly("foo"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredWords().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("deferred", "foo", "add.filter"); + assertThat( + context + .getDeferredTokens() + .stream() + .flatMap(deferredToken -> deferredToken.getUsedDeferredBases().stream()) + .collect(Collectors.toSet()) + ) + .containsExactlyInAnyOrder("deferred", "foo", "add"); + context.remove("foo"); + context.put("deferred", 2); + context.setDeferredExecutionMode(false); + assertThat(interpreter.render(result)).isEqualTo(interpreter.render(template)); // 1 + 2 + 2 = 5 + } + + @Test + public void itUnwrapsRawTags() { + String template = "{% set foo %}{% raw %}{%{% endraw %}{% endset %}"; + interpreter.render(template); + assertThat(interpreter.getContext().get("foo")).isEqualTo("{%"); + } + + @Test + public void itUnwrapsEmptyAdjacentRawTags() { + String template = + "{% set foo %}A{% raw %}{% endraw %}{% raw %}{% endraw %}B{% endset %}"; + interpreter.render(template); + assertThat(interpreter.getContext().get("foo")).isEqualTo("AB"); + } + + @Test + public void itDoesNotDoExtraNestedInterpretationWhenUnwrappingRaw() { + String template = + "{% set foo %}{% print '{{ 1 + 1 }}' %}{% raw %}{% endraw %}{{ deferred }}B{% endset %}"; + String result = interpreter.render(template); + interpreter.getContext().put("deferred", "resolved"); + interpreter.render(result); + assertThat(interpreter.getContext().get("foo")).isEqualTo("{{ 1 + 1 }}resolvedB"); + } + + @Test + @Override + @Ignore + public void itThrowsAndDefersVarWhenValContainsDeferred() { + // Deferred values are handled differently. Test does not apply. + } + + @Test + @Override + @Ignore + public void itThrowsAndDefersMultiVarWhenValContainsDeferred() { + // Deferred values are handled differently. Test does not apply. + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecoratorTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecoratorTest.java new file mode 100644 index 000000000..f6726ae9d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagDecoratorTest.java @@ -0,0 +1,179 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.lib.tag.Tag; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.testobjects.EagerTagDecoratorTestObjects; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.ArrayList; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class EagerTagDecoratorTest extends BaseInterpretingTest { + + private static final long MAX_OUTPUT_SIZE = 50L; + private Tag mockTag; + private EagerGenericTag eagerTagDecorator; + + @Before + public void eagerSetup() throws Exception { + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "add_to_context", + this.getClass().getDeclaredMethod("addToContext", String.class, Object.class) + ) + ); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "modify_context", + this.getClass().getDeclaredMethod("modifyContext", String.class, Object.class) + ) + ); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + mockTag = mock(Tag.class); + eagerTagDecorator = new EagerGenericTag<>(mockTag); + + JinjavaInterpreter.pushCurrent(interpreter); + context.put("deferred", DeferredValue.instance()); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itDoesntLimitShortString() { + String template = "{% if true %}abc{% endif %}"; + + TagNode tagNode = (TagNode) (interpreter.parse(template).getChildren().get(0)); + assertThat(eagerTagDecorator.eagerInterpret(tagNode, interpreter, null)) + .isEqualTo(template); + assertThat(interpreter.getErrors()).hasSize(0); + } + + @Test + public void itLimitsEagerInterpretLength() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < MAX_OUTPUT_SIZE; i++) { + tooLong.append(i); + } + TagNode tagNode = (TagNode) (interpreter + .parse(String.format("{%% raw %%}%s{%% endraw %%}", tooLong.toString())) + .getChildren() + .get(0)); + assertThatThrownBy(() -> eagerTagDecorator.eagerInterpret(tagNode, interpreter, null)) + .isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itLimitsInterpretLength() { + when(mockTag.interpret(any(), any())).thenThrow(new DeferredValueException("")); + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < MAX_OUTPUT_SIZE; i++) { + tooLong.append(i); + } + TagNode tagNode = (TagNode) (interpreter + .parse(String.format("{%% raw %%}%s{%% endraw %%}", tooLong.toString())) + .getChildren() + .get(0)); + assertThatThrownBy(() -> eagerTagDecorator.interpret(tagNode, interpreter)) + .isInstanceOf(DeferredValueException.class); + } + + @Test + public void itLimitsTagLength() { + TagNode tagNode = (TagNode) (interpreter + .parse("{% print range(0, 50) %}") + .getChildren() + .get(0)); + assertThatThrownBy(() -> + eagerTagDecorator.getEagerTagImage((TagToken) tagNode.getMaster(), interpreter) + ) + .isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itPutsOnContextInChildContext() { + assertThat(interpreter.render("{{ add_to_context('foo', 'bar') }}{{ foo }}")) + .isEqualTo("bar"); + } + + @Test + public void itModifiesContextInChildContext() { + context.put("foo", new ArrayList<>()); + assertThat(interpreter.render("{{ modify_context('foo', 'bar') }}{{ foo }}")) + .isEqualTo("['bar']"); + } + + @Test + public void itDoesntModifyContextWhenResultIsDeferred() { + context.put("foo", new ArrayList<>()); + assertThat( + interpreter.render("{{ modify_context('foo', 'bar') ~ deferred }}{{ foo }}") + ) + .isEqualTo("{{ null ~ deferred }}['bar']"); + } + + public static void addToContext(String key, Object value) { + JinjavaInterpreter.getCurrent().getContext().put(key, value); + } + + public static void modifyContext(String key, Object value) { + ((List) JinjavaInterpreter.getCurrent().getContext().get(key)).add(value); + } + + @Test + public void itDefersNodeWhenOutputTooBigIsThrownWithinInnerInterpret() { + EagerTagDecoratorTestObjects.TooBig tooBig = new EagerTagDecoratorTestObjects.TooBig( + new ArrayList<>() + ); + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + interpreter.getContext().put("too_big", tooBig); + interpreter.render( + "{% for i in range(2) %}{% do too_big.append(deferred) %}{% endfor %}" + ); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactoryTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactoryTest.java new file mode 100644 index 000000000..491c16415 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactoryTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.lib.tag.IfchangedTag; +import com.hubspot.jinjava.lib.tag.Tag; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.Test; + +public class EagerTagFactoryTest { + + @Test + public void itGetsEagerTagDecoratorForOverrides() { + Set> eagerTagDecoratorSet = EagerTagFactory.EAGER_TAG_OVERRIDES + .keySet() + .stream() + .map(clazz -> { + try { + return clazz.getDeclaredConstructor().newInstance(); + } catch (Exception ignored) { + return null; + } + }) + .filter(Objects::nonNull) + .map(EagerTagFactory::getEagerTagDecorator) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + assertThat(eagerTagDecoratorSet.size()) + .isEqualTo(EagerTagFactory.EAGER_TAG_OVERRIDES.keySet().size()); + assertThat( + eagerTagDecoratorSet + .stream() + .map(e -> e.getTag().getClass()) + .collect(Collectors.toSet()) + ) + .isEqualTo(EagerTagFactory.EAGER_TAG_OVERRIDES.keySet()); + } + + @Test + public void itGetsEagerTagDecoratorForNonOverride() { + Optional> maybeEagerGenericTag = + EagerTagFactory.getEagerTagDecorator(new IfchangedTag()); + assertThat(maybeEagerGenericTag).isPresent(); + assertThat(maybeEagerGenericTag.get()).isInstanceOf(EagerGenericTag.class); + assertThat(maybeEagerGenericTag.get().getTag()).isInstanceOf(IfchangedTag.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTagTest.java new file mode 100644 index 000000000..598af35fb --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerUnlessTagTest.java @@ -0,0 +1,73 @@ +package com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.ExpectedNodeInterpreter; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.UnlessTagTest; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.tree.parse.TagToken; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerUnlessTagTest extends UnlessTagTest { + + private ExpectedNodeInterpreter expectedNodeInterpreter; + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + tag = new EagerUnlessTag(); + context.registerTag(tag); + context.put("deferred", DeferredValue.instance()); + expectedNodeInterpreter = + new ExpectedNodeInterpreter(interpreter, tag, "tags/eager/unlesstag"); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itHandlesDeferredInRegular() { + context.put("foo", true); + expectedNodeInterpreter.assertExpectedOutput("handles-deferred-in-regular"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeEagerTagToken).isEmpty(); + } + + @Test + public void itHandlesDeferredInEager() { + context.put("foo", 1); + expectedNodeInterpreter.assertExpectedOutput("handles-deferred-in-eager"); + Optional maybeEagerTagToken = context + .getDeferredTokens() + .stream() + .filter(e -> e.getToken() instanceof TagToken) + .filter(e -> ((TagToken) e.getToken()).getTagName().equals(tag.getName())) + .findAny(); + assertThat(maybeEagerTagToken).isPresent(); + assertThat(maybeEagerTagToken.get().getSetDeferredWords()).isEmpty(); + assertThat(maybeEagerTagToken.get().getUsedDeferredWords()) + .containsExactly("deferred"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/loader/CascadingResourceLocatorTest.java b/src/test/java/com/hubspot/jinjava/loader/CascadingResourceLocatorTest.java index 6af5c286a..35a6f4112 100644 --- a/src/test/java/com/hubspot/jinjava/loader/CascadingResourceLocatorTest.java +++ b/src/test/java/com/hubspot/jinjava/loader/CascadingResourceLocatorTest.java @@ -3,15 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.nio.charset.StandardCharsets; - import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; - -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) @SuppressWarnings("unchecked") @@ -19,8 +17,10 @@ public class CascadingResourceLocatorTest { @Mock ResourceLocator first; + @Mock ResourceLocator second; + @Mock JinjavaInterpreter interpreter; @@ -34,20 +34,25 @@ public void setup() { @Test public void itUsesResponseFromFirstIfPresent() throws Exception { when(first.getString("foo", StandardCharsets.UTF_8, interpreter)).thenReturn("bar"); - assertThat(locator.getString("foo", StandardCharsets.UTF_8, interpreter)).isEqualTo("bar"); + assertThat(locator.getString("foo", StandardCharsets.UTF_8, interpreter)) + .isEqualTo("bar"); } @Test public void itUsesResponseFromSecondWhenNotFoundOnFirst() throws Exception { - when(first.getString("foo", StandardCharsets.UTF_8, interpreter)).thenThrow(ResourceNotFoundException.class); + when(first.getString("foo", StandardCharsets.UTF_8, interpreter)) + .thenThrow(ResourceNotFoundException.class); when(second.getString("foo", StandardCharsets.UTF_8, interpreter)).thenReturn("bar"); - assertThat(locator.getString("foo", StandardCharsets.UTF_8, interpreter)).isEqualTo("bar"); + assertThat(locator.getString("foo", StandardCharsets.UTF_8, interpreter)) + .isEqualTo("bar"); } @Test(expected = ResourceNotFoundException.class) public void notFoundWhenAllLocatorsReturnNotFound() throws Exception { - when(first.getString("foo", StandardCharsets.UTF_8, interpreter)).thenThrow(ResourceNotFoundException.class); - when(second.getString("foo", StandardCharsets.UTF_8, interpreter)).thenThrow(ResourceNotFoundException.class); + when(first.getString("foo", StandardCharsets.UTF_8, interpreter)) + .thenThrow(ResourceNotFoundException.class); + when(second.getString("foo", StandardCharsets.UTF_8, interpreter)) + .thenThrow(ResourceNotFoundException.class); locator.getString("foo", StandardCharsets.UTF_8, interpreter); } } diff --git a/src/test/java/com/hubspot/jinjava/loader/ClasspathResourceLocatorTest.java b/src/test/java/com/hubspot/jinjava/loader/ClasspathResourceLocatorTest.java index aaeb41675..3b63b2711 100644 --- a/src/test/java/com/hubspot/jinjava/loader/ClasspathResourceLocatorTest.java +++ b/src/test/java/com/hubspot/jinjava/loader/ClasspathResourceLocatorTest.java @@ -2,31 +2,23 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseInterpretingTest; import java.nio.charset.StandardCharsets; - -import org.junit.Before; import org.junit.Test; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - -public class ClasspathResourceLocatorTest { - - JinjavaInterpreter interpreter; - - @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); - } +public class ClasspathResourceLocatorTest extends BaseInterpretingTest { @Test public void testLoadFromClasspath() throws Exception { - assertThat(new ClasspathResourceLocator().getString("loader/cp/foo/bar.jinja", StandardCharsets.UTF_8, interpreter)).isEqualTo("hello world."); + assertThat( + new ClasspathResourceLocator() + .getString("loader/cp/foo/bar.jinja", StandardCharsets.UTF_8, interpreter) + ) + .isEqualTo("hello world."); } @Test(expected = ResourceNotFoundException.class) public void itThrowsNotFoundWhenNotFound() throws Exception { new ClasspathResourceLocator().getString("foo", StandardCharsets.UTF_8, interpreter); } - } diff --git a/src/test/java/com/hubspot/jinjava/loader/FileLocatorTest.java b/src/test/java/com/hubspot/jinjava/loader/FileLocatorTest.java index b248f6573..9e13e51d3 100644 --- a/src/test/java/com/hubspot/jinjava/loader/FileLocatorTest.java +++ b/src/test/java/com/hubspot/jinjava/loader/FileLocatorTest.java @@ -2,20 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Files; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.File; import java.io.FileNotFoundException; import java.nio.charset.StandardCharsets; - import org.junit.Before; import org.junit.Test; -import com.google.common.io.Files; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; - -public class FileLocatorTest { - - JinjavaInterpreter interpreter; +public class FileLocatorTest extends BaseInterpretingTest { FileLocator locatorWorkingDir; FileLocator locatorTmpDir; @@ -25,11 +22,11 @@ public class FileLocatorTest { @Before public void setUp() throws Exception { - interpreter = new Jinjava().newInterpreter(); - locatorWorkingDir = new FileLocator(); - File tmpDir = java.nio.file.Files.createTempDirectory(getClass().getSimpleName()).toFile(); + File tmpDir = java.nio.file.Files + .createTempDirectory(getClass().getSimpleName()) + .toFile(); locatorTmpDir = new FileLocator(tmpDir); first = new File(tmpDir, "foo/first.jinja"); @@ -44,22 +41,46 @@ public void setUp() throws Exception { @Test public void testWorkingDirRelative() throws Exception { - assertThat(locatorWorkingDir.getString("target/loader-test-data/second.jinja", StandardCharsets.UTF_8, interpreter)).isEqualTo("second"); + assertThat( + locatorWorkingDir.getString( + "target/loader-test-data/second.jinja", + StandardCharsets.UTF_8, + interpreter + ) + ) + .isEqualTo("second"); } @Test public void testWorkingDirAbs() throws Exception { - assertThat(locatorWorkingDir.getString(second.getAbsolutePath(), StandardCharsets.UTF_8, interpreter)).isEqualTo("second"); + assertThat( + locatorWorkingDir.getString( + second.getAbsolutePath(), + StandardCharsets.UTF_8, + interpreter + ) + ) + .isEqualTo("second"); } @Test public void testTmpDirRel() throws Exception { - assertThat(locatorTmpDir.getString("foo/first.jinja", StandardCharsets.UTF_8, interpreter)).isEqualTo("first"); + assertThat( + locatorTmpDir.getString("foo/first.jinja", StandardCharsets.UTF_8, interpreter) + ) + .isEqualTo("first"); } @Test public void testTmpDirAbs() throws Exception { - assertThat(locatorTmpDir.getString(first.getAbsolutePath(), StandardCharsets.UTF_8, interpreter)).isEqualTo("first"); + assertThat( + locatorTmpDir.getString( + first.getAbsolutePath(), + StandardCharsets.UTF_8, + interpreter + ) + ) + .isEqualTo("first"); } @Test(expected = FileNotFoundException.class) @@ -81,5 +102,4 @@ public void testNotFoundRel() throws Exception { public void testNotFoundAbs() throws Exception { locatorWorkingDir.getString("/blargh", StandardCharsets.UTF_8, interpreter); } - } diff --git a/src/test/java/com/hubspot/jinjava/objects/NamespaceTest.java b/src/test/java/com/hubspot/jinjava/objects/NamespaceTest.java new file mode 100644 index 000000000..8f6e78732 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/NamespaceTest.java @@ -0,0 +1,41 @@ +package com.hubspot.jinjava.objects; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.junit.Before; +import org.junit.Test; + +public class NamespaceTest { + + private Namespace namespace; + + @Before + public void setup() { + namespace = new Namespace(); + } + + @Test + public void shouldReturnNullIfValueDoNotExists() { + String key = "key"; + Object result = namespace.get(key); + assertThat(result).isEqualTo(null); + } + + @Test + public void shouldReplaceValueForKeyIfValueForKeyExists() { + String key = "key"; + namespace.put(key, Boolean.TRUE); + namespace.put(key, "second value"); + + Object result = namespace.get(key); + assertThat(result).isEqualTo("second value"); + } + + @Test + public void shouldSetValueIfValueDoesNotExists() { + String key = "key"; + String value = "Test"; + namespace.put(key, value); + assertThat(namespace.get(key)).isEqualTo(value); + } +} diff --git a/src/test/java/com/hubspot/jinjava/objects/collections/PyListTest.java b/src/test/java/com/hubspot/jinjava/objects/collections/PyListTest.java new file mode 100644 index 000000000..d511a9e7f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/collections/PyListTest.java @@ -0,0 +1,299 @@ +package com.hubspot.jinjava.objects.collections; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.interpret.IndexOutOfRangeException; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; + +public class PyListTest extends BaseJinjavaTest { + + @Test + public void itSupportsAppendOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.append(4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4]"); + } + + @Test + public void itSupportsExtendOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.extend([4, 5, 6]) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4, 5, 6]"); + } + + @Test + public void itSupportsExtendOperationWithNullList() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.extend(null) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3]"); + } + + @Test + public void itSupportsInsertOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(1, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 4, 2, 3]"); + } + + @Test + public void itSupportsInsertOperationWithNegativeIndex() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(-1, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 4, 3]"); + } + + @Test + public void itSupportsInsertOperationWithLargeNegativeIndex() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(-99, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[4, 1, 2, 3]"); + } + + @Test + public void itHandlesInsertOperationOutOfRange() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(99, 4) %}" + "{{ test }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo("[1, 2, 3]"); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itSupportsPopOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop() }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 2]"); + } + + @Test + public void itSupportsPopAtIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop(1) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("2[1, 3]"); + } + + @Test + public void itSupportsPopAtNegativeIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop(-1) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 2]"); + } + + @Test + public void itThrowsIndexOutOfRangeForPopOfEmptyList() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [] %}" + "{{ test.pop() }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo(""); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itThrowsIndexOutOfRangeForPopOutOfRange() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [1] %}" + "{{ test.pop(1) }},{{ test.pop(0) }},{{ test.pop(0) }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo(",1,"); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itSupportsClearOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.clear() %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[]"); + } + + @Test + public void itSupportsCountOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 1, 2, 2, 2, 3] %}" + "{{ test.count(2) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 1, 2, 2, 2, 3]"); + } + + @Test + public void itSupportsReverseOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.reverse() %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[3, 2, 1]"); + } + + @Test + public void itSupportsCopyOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + + "{% set test2 = test.copy() %}" + + "{% do test.append(4) %}" + + "{{ test }}{{test2}}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4][1, 2, 3]"); + } + + @Test + public void itSupportsIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(20) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("1"); + } + + @Test + public void itSupportsIndexWithinBoundsOperation() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(20, 2, 6) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("4"); + } + + @Test + public void itReturnsNegativeOneForMissingObjectForIndex() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(999) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("-1"); + } + + @Test + public void itReturnsNegativeOneForMissingObjectForIndexWithinBounds() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(999, 1, 5) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("-1"); + } + + @Test + public void itDisallowsInsertingSelf() { + assertThat( + jinjava.render( + "{% set test = [1,2] %}" + "{% do test.insert(0, test) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2]"); + } + + @Test + public void itDisallowsAppendingSelf() { + assertThat( + jinjava.render( + "{% set test = [1, 2] %}" + "{% do test.append(test) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2]"); + } + + @Test + public void itComputesHashCodeWhenListContainsItself() { + PyList list1 = new PyList(new ArrayList<>()); + PyList list2 = new PyList(new ArrayList<>()); + list1.add(list2); + int initialHashCode = list1.hashCode(); + list2.add(list1); + int hashCodeWithInfiniteRecursion = list1.hashCode(); + assertThat(initialHashCode).isNotEqualTo(hashCodeWithInfiniteRecursion); + assertThat(list1.hashCode()) + .isEqualTo(hashCodeWithInfiniteRecursion) + .describedAs("Hash code should be consistent on multiple calls"); + assertThat(list2.hashCode()) + .isEqualTo(list1.hashCode()) + .describedAs( + "The two lists are currently the same as they are both a list1 of a single infinitely recurring list" + ); + list1.add(123456); + assertThat(list2.hashCode()) + .isNotEqualTo(list1.hashCode()) + .describedAs( + "The two lists are no longer the same as list1 has 2 elements while list2 has one" + ); + PyList copy = list1.copy(); + assertThat(copy.hashCode()) + .isNotEqualTo(list1.hashCode()) + .describedAs( + "copy is not the same as list1 because it is a list of a list of recursion, whereas list1 is a list of recursion" + ); + assertThat(list1.copy().hashCode()) + .isEqualTo(copy.hashCode()) + .describedAs("All copies should have the same hash code"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/objects/collections/PyMapTest.java b/src/test/java/com/hubspot/jinjava/objects/collections/PyMapTest.java new file mode 100644 index 000000000..0bb4bfa33 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/collections/PyMapTest.java @@ -0,0 +1,461 @@ +package com.hubspot.jinjava.objects.collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.collect.ImmutableList; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.IndexOutOfRangeException; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import org.junit.Test; + +public class PyMapTest extends BaseJinjavaTest { + + @Test + public void itSupportsAppendOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.append(4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4]"); + } + + @Test + public void itSupportsExtendOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.extend([4, 5, 6]) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4, 5, 6]"); + } + + @Test + public void itSupportsInsertOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(1, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 4, 2, 3]"); + } + + @Test + public void itSupportsInsertOperationWithNegativeIndex() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(-1, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 4, 3]"); + } + + @Test + public void itSupportsInsertOperationWithLargeNegativeIndex() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(-99, 4) %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[4, 1, 2, 3]"); + } + + @Test + public void itHandlesInsertOperationOutOfRange() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [1, 2, 3] %}" + "{% do test.insert(99, 4) %}" + "{{ test }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo("[1, 2, 3]"); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itSupportsPopOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop() }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 2]"); + } + + @Test + public void itSupportsPopAtIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop(1) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("2[1, 3]"); + } + + @Test + public void itSupportsPopAtNegativeIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{{ test.pop(-1) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 2]"); + } + + @Test + public void itThrowsIndexOutOfRangeForPopOfEmptyList() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [] %}" + "{{ test.pop() }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo(""); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itThrowsIndexOutOfRangeForPopOutOfRange() { + RenderResult renderResult = jinjava.renderForResult( + "{% set test = [1] %}" + "{{ test.pop(1) }},{{ test.pop(0) }},{{ test.pop(0) }}", + Collections.emptyMap() + ); + + assertThat(renderResult.getOutput()).isEqualTo(",1,"); + assertThat(renderResult.getErrors().get(0)).isInstanceOf(TemplateError.class); + assertThat(renderResult.getErrors().get(0).getException().getCause()) + .isInstanceOf(IndexOutOfRangeException.class); + } + + @Test + public void itSupportsClearOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.clear() %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[]"); + } + + @Test + public void itSupportsCountOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 1, 2, 2, 2, 3] %}" + "{{ test.count(2) }}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("3[1, 1, 2, 2, 2, 3]"); + } + + @Test + public void itSupportsReverseOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + "{% do test.reverse() %}" + "{{ test }}", + Collections.emptyMap() + ) + ) + .isEqualTo("[3, 2, 1]"); + } + + @Test + public void itSupportsCopyOperation() { + assertThat( + jinjava.render( + "{% set test = [1, 2, 3] %}" + + "{% set test2 = test.copy() %}" + + "{% do test.append(4) %}" + + "{{ test }}{{test2}}", + Collections.emptyMap() + ) + ) + .isEqualTo("[1, 2, 3, 4][1, 2, 3]"); + } + + @Test + public void itSupportsIndexOperation() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(20) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("1"); + } + + @Test + public void itSupportsIndexWithinBoundsOperation() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(20, 2, 6) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("4"); + } + + @Test + public void itReturnsNegativeOneForMissingObjectForIndex() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(999) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("-1"); + } + + @Test + public void itReturnsNegativeOneForMissingObjectForIndexWithinBounds() { + assertThat( + jinjava.render( + "{% set test = [10, 20, 30, 10, 20, 30] %}" + "{{ test.index(999, 1, 5) }}", + Collections.emptyMap() + ) + ) + .isEqualTo("-1"); + } + + @Test + public void itDisallowsSelfReferencingPut() { + PyMap pyMap = new PyMap(new HashMap()); + assertThatThrownBy(() -> pyMap.put("x", pyMap)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Can't add map object to itself"); + } + + @Test + public void itDisallowsSelfReferencingUpdate() { + PyMap pyMap = new PyMap(new HashMap()); + assertThatThrownBy(() -> pyMap.update(pyMap)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Can't update map object with itself"); + } + + @Test + public void itDisallowsSelfReferencingPutAll() { + PyMap pyMap = new PyMap(new HashMap()); + assertThatThrownBy(() -> pyMap.putAll(pyMap)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Map putAll() operation can't be used to add map to itself"); + } + + @Test + public void itUpdatesKeysWithStaticName() { + assertThat( + jinjava.render( + "{% set test = {\"key1\": \"value1\"} %}" + + "{% do test.update({\"key1\": \"value2\"}) %}" + + "{{ test[\"key1\"] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value2"); + } + + @Test + public void itDoesntSetKeysWithVariableNameWhenLegacy() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withEvaluateMapKeys(false).build() + ) + .build() + ); + assertThat( + jinjava.render( + "{% set keyName = \"key1\" %}" + + "{% set test = {keyName: \"value1\"} %}" + + "{{ test['keyName'] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value1"); + } + + @Test + public void itSetsKeysWithVariableName() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withEvaluateMapKeys(true).build() + ) + .build() + ); + + assertThat( + jinjava.render( + "{% set keyName = \"key1\" %}" + + "{% set test = {keyName: \"value1\"} %}" + + "{{ test[keyName] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value1"); + } + + @Test + public void itGetsKeysWithVariableName() { + assertThat( + jinjava.render( + "{% set test = {\"key1\": \"value1\"} %}" + + "{% set keyName = \"key1\" %}" + + "{{ test[keyName] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value1"); + } + + @Test + public void itSupportsGetWithOptionalDefault() { + assertThat( + jinjava.render( + "{% set test = {\"key1\": \"value1\"} %}" + + "{{ test.get(\"key2\", \"default\") }}", + Collections.emptyMap() + ) + ) + .isEqualTo("default"); + } + + @Test + public void itFallsBackUnknownVariableNameToString() { + assertThat( + jinjava.render( + "{% set test = {keyName: \"value1\"} %}" + "{{ test[\"keyName\"] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value1"); + } + + @Test + public void itDoesntUpdateKeysWithVariableNameWhenLegacy() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withEvaluateMapKeys(false) + .build() + ) + .build() + ); + assertThat( + jinjava.render( + "{% set test = {\"key1\": \"value1\"} %}" + + "{% set keyName = \"key1\" %}" + + "{% do test.update({keyName: \"value2\"}) %}" + + "{{ test['key1'] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value1"); + } + + @Test + public void itUpdatesKeysWithVariableName() { + jinjava = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withEvaluateMapKeys(true).build() + ) + .build() + ); + assertThat( + jinjava.render( + "{% set test = {\"key1\": \"value1\"} %}" + + "{% set keyName = \"key1\" %}" + + "{% do test.update({keyName: \"value2\"}) %}" + + "{{ test[keyName] }}", + Collections.emptyMap() + ) + ) + .isEqualTo("value2"); + } + + @Test + public void itComputesHashCodeWhenContainedWithinItself() { + PyMap map = new PyMap(new HashMap<>()); + map.put("map1key1", "value1"); + + PyMap map2 = new PyMap(new HashMap<>()); + map2.put("map2key1", map); + + map.put("map1key2", map2); + + assertThat(map.hashCode()).isNotEqualTo(0); + } + + @Test + public void itComputesHashCodeWhenContainedWithinItselfWithFurtherEntries() { + PyMap map = new PyMap(new HashMap<>()); + map.put("map1key1", "value1"); + + PyMap map2 = new PyMap(new HashMap<>()); + map2.put("map2key1", map); + + map.put("map1key2", map2); + + int originalHashCode = map.hashCode(); + map2.put("newKey", "newValue"); + int newHashCode = map.hashCode(); + assertThat(originalHashCode).isNotEqualTo(newHashCode); + } + + @Test + public void itComputesHashCodeWhenContainedWithinItselfInsideList() { + PyMap map = new PyMap(new HashMap<>()); + map.put("map1key1", "value1"); + + PyMap map2 = new PyMap(new HashMap<>()); + map2.put("map2key1", map); + + map.put("map1key2", new PyList(ImmutableList.of((map2)))); + + assertThat(map.hashCode()).isNotEqualTo(0); + } + + @Test + public void itComputesHashCodeWithNullKeysAndValues() { + PyMap map = new PyMap(new HashMap<>()); + map.put(null, "value1"); + + PyMap map2 = new PyMap(new HashMap<>()); + map2.put("map2key1", map); + + PyList list = new PyList(new ArrayList<>()); + list.add(null); + map.put("map1key2", new PyList(list)); + + assertThat(map.hashCode()).isNotEqualTo(0); + } +} diff --git a/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyListTest.java b/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyListTest.java new file mode 100644 index 000000000..ece709a75 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyListTest.java @@ -0,0 +1,93 @@ +package com.hubspot.jinjava.objects.collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class SizeLimitingPyListTest extends BaseInterpretingTest { + + @Test + public void itDoesntLimitByDefault() { + SizeLimitingPyList objects = new SizeLimitingPyList( + createList(10), + Integer.MAX_VALUE + ); + assertThat(objects.size()).isEqualTo(10); + } + + @Test(expected = CollectionTooBigException.class) + public void itLimitsOnCreate() { + new SizeLimitingPyList(createList(4), 2); + } + + @Test + public void itWarnsOnAdd() { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + SizeLimitingPyList objects = new SizeLimitingPyList(new ArrayList<>(), 10); + int i; + for (i = 1; i < 9; i++) { + objects.add(i); + assertThat(interpreter.getErrors()).isEmpty(); + } + objects.add(i++); + assertThat(interpreter.getErrors().size()).isEqualTo(1); + assertThat(interpreter.getErrors().get(0).getException()) + .isInstanceOf(CollectionTooBigException.class); + objects.add(i++); + + assertThatThrownBy(() -> objects.add(10)) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnAdd() { + List list = createList(3); + SizeLimitingPyList objects = new SizeLimitingPyList(new ArrayList<>(), 2); + objects.add(list.get(0)); + objects.add(list.get(1)); + assertThatThrownBy(() -> objects.add(list.get(2))) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnAppend() { + List list = createList(3); + SizeLimitingPyList objects = new SizeLimitingPyList(new ArrayList<>(), 2); + objects.append(list.get(0)); + objects.append(list.get(1)); + assertThatThrownBy(() -> objects.append(list.get(2))) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnInsert() { + List list = createList(3); + SizeLimitingPyList objects = new SizeLimitingPyList(new ArrayList<>(), 2); + objects.add(list.get(0)); + objects.add(list.get(1)); + assertThatThrownBy(() -> objects.insert(1, list.get(2))) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnAddAll() { + List list = createList(3); + SizeLimitingPyList objects = new SizeLimitingPyList(new ArrayList<>(), 2); + assertThatThrownBy(() -> objects.addAll(list)) + .isInstanceOf(CollectionTooBigException.class); + } + + public static List createList(int size) { + List result = new ArrayList<>(); + for (int i = 0; i < size; i++) { + result.add(i + 1); + } + return result; + } +} diff --git a/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMapTest.java b/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMapTest.java new file mode 100644 index 000000000..a3a50da56 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/collections/SizeLimitingPyMapTest.java @@ -0,0 +1,87 @@ +package com.hubspot.jinjava.objects.collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.CollectionTooBigException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.junit.Test; + +public class SizeLimitingPyMapTest extends BaseInterpretingTest { + + @Test + public void itDoesntLimitByDefault() { + SizeLimitingPyMap objects = new SizeLimitingPyMap(createMap(10), Integer.MAX_VALUE); + assertThat(objects.size()).isEqualTo(10); + } + + @Test(expected = CollectionTooBigException.class) + public void itLimitsOnCreate() { + new SizeLimitingPyMap(createMap(3), 2); + } + + @Test + public void itWarnsOnPut() { + JinjavaInterpreter interpreter = JinjavaInterpreter.getCurrent(); + int i = 8; + SizeLimitingPyMap objects = new SizeLimitingPyMap(createMap(i), 10); + assertThat(interpreter.getErrors()).isEmpty(); + + objects.put(Objects.toString(i + 1), ++i); + assertThat(interpreter.getErrors().size()).isEqualTo(1); + assertThat(interpreter.getErrors().get(0).getException()) + .isInstanceOf(CollectionTooBigException.class); + + objects.put(Objects.toString(i + 1), ++i); + + assertThatThrownBy(() -> objects.put(Objects.toString(11), 11)) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnPut() { + SizeLimitingPyMap objects = new SizeLimitingPyMap(new HashMap<>(), 2); + objects.put("1", "foo"); + objects.put("2", "foo"); + objects.put("1", "foo"); + objects.put("2", "foo"); + objects.put("2", "foo"); + objects.put("2", "foo"); + assertThatThrownBy(() -> objects.put("3", "foo")) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itLimitsOnPutAll() { + SizeLimitingPyMap objects = new SizeLimitingPyMap(new HashMap<>(), 2); + assertThatThrownBy(() -> objects.putAll(createMap(3))) + .isInstanceOf(CollectionTooBigException.class); + } + + @Test + public void itHandlesNullOnPutAll() { + SizeLimitingPyMap objects = new SizeLimitingPyMap(new HashMap<>(), 10); + objects.putAll(null); + } + + @Test + public void itIgnoresLimitsForDuplicateKeys() { + SizeLimitingPyMap objects = new SizeLimitingPyMap(new HashMap<>(), 2); + objects.putAll(createMap(2)); + assertThat(objects.size()).isEqualTo(2); + objects.putAll(createMap(2)); + assertThat(objects.size()).isEqualTo(2); + } + + private static Map createMap(int size) { + Map result = new HashMap<>(); + for (int i = 0; i < size; i++) { + result.put(String.valueOf(i + 1), i + 1); + } + return result; + } +} diff --git a/src/test/java/com/hubspot/jinjava/objects/date/PyishDateTest.java b/src/test/java/com/hubspot/jinjava/objects/date/PyishDateTest.java index 7ce40672c..b732811b2 100644 --- a/src/test/java/com/hubspot/jinjava/objects/date/PyishDateTest.java +++ b/src/test/java/com/hubspot/jinjava/objects/date/PyishDateTest.java @@ -2,10 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Date; - import org.junit.Test; public class PyishDateTest { @@ -16,6 +21,28 @@ public void itUsesCurrentTimeWhenNoneProvided() { assertThat(d.toDate()).isCloseTo(new Date(), 10000); } + @Test + public void itUsesDateTimeProviderWhenNoTimeProvided() { + long ts = 123345414223L; + + JinjavaInterpreter.pushCurrent( + new JinjavaInterpreter( + new Jinjava(), + new Context(), + BaseJinjavaTest + .newConfigBuilder() + .withDateTimeProvider(new FixedDateTimeProvider(ts)) + .build() + ) + ); + try { + PyishDate d = new PyishDate((Long) null); + assertThat(d.toDate()).isEqualTo(Date.from(Instant.ofEpochMilli(ts))); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + @Test public void testStrfmt() { PyishDate d = new PyishDate(ZonedDateTime.parse("2013-11-12T14:15:00+00:00")); @@ -56,4 +83,66 @@ public void testNullDateTimeNotAllowed() { public void testNullStringNotAllowed() { new PyishDate((String) null); } + + @Test + public void itPyishSerializes() { + PyishDate d1 = new PyishDate(ZonedDateTime.parse("2013-11-12T14:15:16.170+02:00")); + JinjavaInterpreter interpreter = new Jinjava().newInterpreter(); + interpreter.render("{% set foo = " + PyishObjectMapper.getAsPyishString(d1) + "%}"); + assertThat(d1).isEqualTo(interpreter.getContext().get("foo")); + } + + @Test + public void itPyishSerializesWithCustomDateFormat() { + PyishDate d1 = new PyishDate(ZonedDateTime.parse("2013-11-12T14:15:16.170+02:00")); + d1.setDateFormat("yyyy-MM-dd"); + JinjavaInterpreter interpreter = new Jinjava().newInterpreter(); + interpreter.render("{% set foo = " + PyishObjectMapper.getAsPyishString(d1) + "%}"); + PyishDate reconstructed = (PyishDate) interpreter.getContext().get("foo"); + assertThat(reconstructed.toString()).isEqualTo("2013-11-12"); + } + + @Test + public void itDoesntLoseSecondsOnReconstruction() { + PyishDate d1 = new PyishDate(ZonedDateTime.parse("2013-11-12T14:15:16.170+02:00")); + d1.setDateFormat("yyyy-MM-dd"); + JinjavaInterpreter interpreter = new Jinjava().newInterpreter(); + interpreter.render("{% set foo = " + PyishObjectMapper.getAsPyishString(d1) + "%}"); + PyishDate reconstructed = (PyishDate) interpreter.getContext().get("foo"); + assertThat(reconstructed.getSecond()).isEqualTo(16); + } + + @Test + public void testPyishDateToStringWithCustomDateFormatter() { + PyishDate d1 = new PyishDate(ZonedDateTime.parse("2013-11-12T14:15:16.170+02:00")); + JinjavaInterpreter interpreter = new Jinjava().newInterpreter(); + JinjavaInterpreter.pushCurrent(interpreter); + try { + interpreter + .getContext() + .put(PyishDate.PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY, "YYYY-MM-dd"); + assertThat(d1.toString()).isEqualTo("2013-11-12"); + interpreter + .getContext() + .put(PyishDate.PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY, "YYYY/MM/dd"); + assertThat(d1.toString()).isEqualTo("2013/11/12"); + interpreter + .getContext() + .put(PyishDate.PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY, "MM/dd/yy"); + assertThat(d1.toString()).isEqualTo("11/12/13"); + interpreter + .getContext() + .remove(PyishDate.PYISH_DATE_CUSTOM_DATE_FORMAT_CONTEXT_KEY); + assertThat(d1.toString()).isEqualTo("2013-11-12 14:15:16"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void testPyishDateCustomDateFormat() { + PyishDate d = new PyishDate(ZonedDateTime.parse("2013-10-31T14:15:16.170+02:00")); + d.setDateFormat("dd - MM - YYYY <> HH:mm:ss"); + assertThat(d.toString()).isEqualTo("31 - 10 - 2013 <> 14:15:16"); + } } diff --git a/src/test/java/com/hubspot/jinjava/objects/date/StrftimeFormatterTest.java b/src/test/java/com/hubspot/jinjava/objects/date/StrftimeFormatterTest.java index 6fc51b8e6..8cc5d71cd 100644 --- a/src/test/java/com/hubspot/jinjava/objects/date/StrftimeFormatterTest.java +++ b/src/test/java/com/hubspot/jinjava/objects/date/StrftimeFormatterTest.java @@ -1,10 +1,14 @@ package com.hubspot.jinjava.objects.date; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Locale; - import org.junit.Before; import org.junit.Test; @@ -15,7 +19,10 @@ public class StrftimeFormatterTest { @Before public void setup() { Locale.setDefault(Locale.ENGLISH); - d = ZonedDateTime.parse("2013-11-06T14:22:00.000+00:00"); + d = + ZonedDateTime + .parse("2013-11-06T14:22:00.123+00:00") + .withZoneSameLocal(ZoneId.of("UTC")); } @Test @@ -31,7 +38,7 @@ public void testDefaultFormat() { @Test public void testCommentsFormat() { assertThat(StrftimeFormatter.format(d, "%B %d, %Y, at %I:%M %p")) - .isEqualTo("November 06, 2013, at 02:22 PM"); + .isEqualTo("November 06, 2013, at 02:22 PM"); } @Test @@ -39,6 +46,11 @@ public void testFormatWithDash() { assertThat(StrftimeFormatter.format(d, "%B %-d, %Y")).isEqualTo("November 6, 2013"); } + @Test + public void testFormatWithTrailingPercent() { + assertThat(StrftimeFormatter.format(d, "%B %-d, %")).isEqualTo("November 6, %"); + } + @Test public void testWithNoPcts() { assertThat(StrftimeFormatter.format(d, "MMMM yyyy")).isEqualTo("November 2013"); @@ -46,22 +58,35 @@ public void testWithNoPcts() { @Test public void testDateTime() { - assertThat(StrftimeFormatter.format(d, "%c")).isEqualTo("Wed Nov 06 14:22:00 2013"); + assertThat(StrftimeFormatter.format(d, "%c")) + .isIn("Nov 6, 2013, 2:22:00 PM", "Nov 6, 2013, 2:22:00 PM"); } @Test public void testDate() { - assertThat(StrftimeFormatter.format(d, "%x")).isEqualTo("11/06/13"); + assertThat(StrftimeFormatter.format(d, "%x")).isEqualTo("11/6/13"); + } + + @Test + public void testDayOfWeekNumber() { + assertThat(StrftimeFormatter.format(d, "%w")).isEqualTo("4"); } @Test public void testTime() { - assertThat(StrftimeFormatter.format(d, "%X")).isEqualTo("14:22:00"); + assertThat(StrftimeFormatter.format(d, "%X")).isIn("2:22:00 PM", "2:22:00 PM"); + } + + @Test + public void testMicrosecs() { + assertThat(StrftimeFormatter.format(d, "%X %f")) + .isIn("2:22:00 PM 123000", "2:22:00 PM 123000"); } @Test public void testWithLL() { - assertThat(StrftimeFormatter.format(d, "yyyy/LL/dd HH:mm")).isEqualTo("2013/11/06 14:22"); + assertThat(StrftimeFormatter.format(d, "yyyy/LL/dd HH:mm")) + .isEqualTo("2013/11/06 14:22"); } @Test @@ -74,8 +99,70 @@ public void testPaddedMinFmt() { @Test public void testFinnishMonths() { - assertThat(StrftimeFormatter.formatter("long").withLocale(Locale.forLanguageTag("fi")).format(d)) - .isEqualTo("6. marraskuuta 2013 klo 14.22.00"); + assertThat(StrftimeFormatter.format(d, "long", Locale.forLanguageTag("fi"))) + .isIn("6. marraskuuta 2013 klo 14.22.00 UTC", "6. marraskuuta 2013 14.22.00 UTC"); + } + + @Test + public void testZoneOutput() { + assertThat(StrftimeFormatter.format(d, "%z")).isEqualTo("+0000"); + assertThat(StrftimeFormatter.format(d, "%Z")).isEqualTo("UTC"); + + ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant( + d.toInstant(), + ZoneId.of("America/New_York") + ); + assertThat(StrftimeFormatter.format(zonedDateTime, "%Z")).isEqualTo("EST"); } + @Test + public void itConvertsNominativeFormats() { + ZonedDateTime zonedDateTime = ZonedDateTime.parse("2019-06-06T14:22:00.000+00:00"); + + assertThat( + StrftimeFormatter.format(zonedDateTime, "%OB", Locale.forLanguageTag("ru")) + ) + .isIn("Июнь", "июнь"); + } + + @Test + public void itThrowsOnInvalidFormats() { + assertThatExceptionOfType(InvalidDateFormatException.class) + .isThrownBy(() -> StrftimeFormatter.format(d, "%d.%é.%Y")) + .withMessage("Invalid date format '%d.%é.%Y'"); + + assertThatExceptionOfType(InvalidDateFormatException.class) + .isThrownBy(() -> StrftimeFormatter.format(d, "%d.%ğ.%Y")) + .withMessage("Invalid date format '%d.%ğ.%Y'"); + } + + @Test + public void itOutputsLiteralPercents() { + assertThat(StrftimeFormatter.format(d, "hi %% there")).isEqualTo("hi % there"); + assertThat(StrftimeFormatter.format(d, "%%")).isEqualTo("%"); + } + + @Test + public void itIgnoresFinalStandalonePercent() { + assertThat(StrftimeFormatter.format(d, "%")).isEqualTo("%"); + } + + @Test + public void itAllowsLiteralCharacters() { + assertThat(StrftimeFormatter.format(d, "1: day %d month %B")) + .isEqualTo("1: day 06 month November"); + } + + @Test + public void itUsesInterpreterLocaleAsDefault() { + try { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest.newConfigBuilder().withLocale(Locale.FRENCH).build() + ); + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + assertThat(StrftimeFormatter.format(d, "%B %-d, %Y")).isEqualTo("novembre 6, 2013"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } } diff --git a/src/test/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapperTest.java b/src/test/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapperTest.java new file mode 100644 index 000000000..483b8bd8a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/objects/serialization/PyishObjectMapperTest.java @@ -0,0 +1,182 @@ +package com.hubspot.jinjava.objects.serialization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.objects.collections.SizeLimitingPyMap; +import com.hubspot.jinjava.testobjects.PyishObjectMapperTestObjects; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.Test; + +public class PyishObjectMapperTest { + + @Test + public void itSerializesMapWithNullKeysAsEmptyString() { + Map map = new SizeLimitingPyMap(new HashMap<>(), 10); + map.put("foo", "bar"); + map.put(null, "null"); + assertThat(PyishObjectMapper.getAsPyishString(map)) + .isEqualTo("{'': 'null', 'foo': 'bar'} "); + } + + @Test + public void itSerializesMapEntrySet() { + SizeLimitingPyMap map = new SizeLimitingPyMap(new HashMap<>(), 10); + map.put("foo", "bar"); + map.put("bar", ImmutableMap.of("foobar", new ArrayList<>())); + String result = PyishObjectMapper.getAsPyishString(map.items()); + assertThat(result) + .isEqualTo("[fn:map_entry('bar', {'foobar': []} ), fn:map_entry('foo', 'bar')]"); + } + + @Test + public void itSerializesMapEntrySetWithLimit() { + SizeLimitingPyMap map = new SizeLimitingPyMap(new HashMap<>(), 10); + map.put("foo", "bar"); + map.put("bar", ImmutableMap.of("foobar", new ArrayList<>())); + + Jinjava jinjava = new Jinjava( + BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(10000).build() + ); + try { + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + String result = PyishObjectMapper.getAsPyishString(map.items()); + assertThat(result) + .isEqualTo("[fn:map_entry('bar', {'foobar': []} ), fn:map_entry('foo', 'bar')]"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itSerializesMapWithNullValues() { + Map map = new SizeLimitingPyMap(new HashMap<>(), 10); + map.put("foo", "bar"); + map.put("foobar", null); + assertThat(PyishObjectMapper.getAsPyishString(map)) + .isEqualTo("{'foobar': null, 'foo': 'bar'} "); + } + + @Test + public void itLimitsDepth() { + final List original = new ArrayList<>(); + List list = original; + for (int i = 0; i < 20; i++) { + List temp = new ArrayList<>(); + list.add("abcdefghijklmnopqrstuvwxyz"); + list.add(temp); + list = temp; + } + list.add("a"); + list.add(original); + try { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(10000).build() + ); + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + assertThatThrownBy(() -> PyishObjectMapper.getAsPyishStringOrThrow(original)) + .as("The string to be serialized is larger than the max output size") + .isInstanceOf(JsonMappingException.class) + .hasCauseInstanceOf(LengthLimitingJsonProcessingException.class) + .hasMessageContaining("Max length of 10000 chars reached"); + assertThatThrownBy(() -> PyishObjectMapper.getAsPyishString(original)) + .isInstanceOf(OutputTooBigException.class); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itLimitsOutputSize() { + String input = RandomStringUtils.random(10002); + try { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(10000).build() + ); + JinjavaInterpreter.pushCurrent(jinjava.newInterpreter()); + assertThatThrownBy(() -> PyishObjectMapper.getAsPyishString(input)) + .isInstanceOf(OutputTooBigException.class) + .hasMessageContaining("over limit of 10000 bytes"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itSerializesToSnakeCaseAccessibleMap() { + assertThat( + PyishObjectMapper.getAsPyishString(new PyishObjectMapperTestObjects.Foo("bar")) + ) + .isEqualTo("{'fooBar': 'bar'} |allow_snake_case"); + } + + @Test + public void itSerializesToSnakeCaseAccessibleMapWhenInMapEntry() { + assertThat( + PyishObjectMapper.getAsPyishString( + new AbstractMap.SimpleImmutableEntry<>( + "foo", + new PyishObjectMapperTestObjects.Foo("bar") + ) + ) + ) + .isEqualTo("fn:map_entry('foo', {'fooBar': 'bar'} |allow_snake_case)"); + } + + @Test + public void itDoesNotConvertToSnakeCaseMapWhenResultIsForOutput() { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build() + ); + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + interpreter.getContext().put("foo", new PyishObjectMapperTestObjects.Foo("bar")); + assertThat(interpreter.render("{{ foo }}")).isEqualTo("{'fooBar': 'bar'}"); + } + + @Test + public void itSerializesToSnakeCaseWhenLegacyOverrideIsSet() { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides + .newBuilder() + .withUsePyishObjectMapper(true) + .withUseSnakeCasePropertyNaming(true) + .build() + ) + .build() + ); + JinjavaInterpreter interpreter = jinjava.newInterpreter(); + try { + JinjavaInterpreter.pushCurrent(interpreter); + interpreter.getContext().put("foo", new PyishObjectMapperTestObjects.Foo("bar")); + assertThat(interpreter.render("{{ foo }}")).isEqualTo("{'foo_bar': 'bar'}"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itSerializesOptional() { + assertThat(PyishObjectMapper.getAsPyishString(Optional.of("foo"))).isEqualTo("'foo'"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/AstDictTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/AstDictTestObjects.java new file mode 100644 index 000000000..68c81e37e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/AstDictTestObjects.java @@ -0,0 +1,36 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.TemplateError; +import java.util.Map; + +public class AstDictTestObjects { + + public static class TestClass { + + private Map myMap; + + public TestClass(Map myMap) { + this.myMap = myMap; + } + + public Map getMyMap() { + return myMap; + } + } + + public static enum TestEnum { + FOO("fooName"), + BAR("barName"); + + private String name; + + TestEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java new file mode 100644 index 000000000..68619a042 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.PartiallyDeferredValue; + +public class EagerAstDotTestObjects { + + public static class Foo implements PartiallyDeferredValue { + + public String getDeferred() { + throw new DeferredValueException("foo.deferred is deferred"); + } + + public String getResolved() { + return "resolved"; + } + + @Override + public Object getOriginalValue() { + return null; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/EagerExpressionResolverTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/EagerExpressionResolverTestObjects.java new file mode 100644 index 000000000..d51006361 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/EagerExpressionResolverTestObjects.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; + +public class EagerExpressionResolverTestObjects { + + public static class Foo { + + private final String bar; + + public Foo(String bar) { + this.bar = bar; + } + + public String bar() { + return bar; + } + + public String echo(String toEcho) { + return toEcho; + } + } + + public static class SomethingExceptionallyPyish implements PyishSerializable { + + private String name; + + public SomethingExceptionallyPyish(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + throw new DeferredValueException("Can't serialize"); + } + } + + public static class SomethingPyish implements PyishSerializable { + + private String name; + + public SomethingPyish(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/EagerImportTagTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/EagerImportTagTestObjects.java new file mode 100644 index 000000000..4610c493e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/EagerImportTagTestObjects.java @@ -0,0 +1,20 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; + +public class EagerImportTagTestObjects { + + public static class PrintPathFilter implements Filter { + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return interpreter.getContext().getCurrentPathStack().peek().orElse("/"); + } + + @Override + public String getName() { + return "print_path"; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/EagerTagDecoratorTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/EagerTagDecoratorTestObjects.java new file mode 100644 index 000000000..e6b675c78 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/EagerTagDecoratorTestObjects.java @@ -0,0 +1,23 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.List; + +public class EagerTagDecoratorTestObjects { + + public static class TooBig extends PyList implements PyishSerializable { + + public TooBig(List list) { + super(list); + } + + @Override + public T appendPyishString(T appendable) + throws IOException { + throw new OutputTooBigException(1, 1); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java new file mode 100644 index 000000000..a9d030ee5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java @@ -0,0 +1,160 @@ +package com.hubspot.jinjava.testobjects; + +import com.google.common.collect.ForwardingList; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.objects.PyWrapper; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public class ExpressionResolverTestObjects { + + public static class MyCustomList extends ForwardingList implements PyWrapper { + + private final List list; + + public MyCustomList(List list) { + this.list = list; + } + + @Override + protected List delegate() { + return list; + } + + public int getTotalCount() { + return list.size(); + } + } + + public static final class MyCustomMap implements Map { + + Map data = ImmutableMap.of("foo", "bar", "two", "2", "size", "777"); + + @Override + public int size() { + return data.size(); + } + + @Override + public boolean isEmpty() { + return data.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return data.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return data.containsValue(value); + } + + @Override + public String get(Object key) { + return data.get(key); + } + + @Override + public String put(String key, String value) { + return null; + } + + @Override + public String remove(Object key) { + return null; + } + + @Override + public void putAll(Map m) {} + + @Override + public void clear() {} + + @Override + public Set keySet() { + return data.keySet(); + } + + @Override + public Collection values() { + return data.values(); + } + + @Override + public Set> entrySet() { + return data.entrySet(); + } + } + + public static class TestClass { + + private boolean touched = false; + private String name = "Amazing test class"; + + public boolean isTouched() { + return touched; + } + + public void touch() { + this.touched = true; + } + + public String getName() { + return name; + } + } + + public static final class MyClass { + + private Date date; + + public MyClass(Date date) { + this.date = date; + } + + public Class getClazz() { + return this.getClass(); + } + + public Date getDate() { + return date; + } + } + + public static final class OptionalProperty { + + private MyClass nested; + private String val; + + public OptionalProperty(MyClass nested, String val) { + this.nested = nested; + this.val = val; + } + + public Optional getNested() { + return Optional.ofNullable(nested); + } + + public Optional getVal() { + return Optional.ofNullable(val); + } + } + + public static final class NestedOptionalProperty { + + private OptionalProperty nested; + + public NestedOptionalProperty(OptionalProperty nested) { + this.nested = nested; + } + + public Optional getNested() { + return Optional.ofNullable(nested); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/FilterOverrideTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/FilterOverrideTestObjects.java new file mode 100644 index 000000000..b235e2a95 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/FilterOverrideTestObjects.java @@ -0,0 +1,26 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; + +public class FilterOverrideTestObjects { + + public static class DescriptiveAddFilter implements Filter { + + @Override + public String getName() { + return "add"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + return ( + var + + " + " + + args[0] + + " = " + + (Integer.parseInt(var.toString()) + Integer.parseInt(args[0])) + ); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/IfTagTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/IfTagTestObjects.java new file mode 100644 index 000000000..9bd2794db --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/IfTagTestObjects.java @@ -0,0 +1,21 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.util.HasObjectTruthValue; + +public class IfTagTestObjects { + + public static class Foo implements HasObjectTruthValue { + + private boolean objectTruthValue = false; + + public Foo setObjectTruthValue(boolean objectTruthValue) { + this.objectTruthValue = objectTruthValue; + return this; + } + + @Override + public boolean getObjectTruthValue() { + return objectTruthValue; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java new file mode 100644 index 000000000..9d10e6cd7 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java @@ -0,0 +1,38 @@ +package com.hubspot.jinjava.testobjects; + +public class JinjavaBeanELResolverTestObjects { + + public static class TempItInvokesBestMethodWithSingleParam { + + public String getResult(int a) { + return "int"; + } + + public String getResult(String a) { + return "String"; + } + + public String getResult(Object a) { + return "Object"; + } + + public String getResult(CharSequence a) { + return "CharSequence"; + } + } + + public static class TempItPrefersPrimitives { + + public String getResult(int a, Integer b) { + return "int Integer"; + } + + public String getResult(int a, Object b) { + return "int Object"; + } + + public String getResult(Number a, int b) { + return "Number int"; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/JinjavaInterpreterTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaInterpreterTestObjects.java new file mode 100644 index 000000000..2bd11365a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaInterpreterTestObjects.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.testobjects; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class JinjavaInterpreterTestObjects { + + public static class Foo { + + private String bar; + + public Foo(String bar) { + this.bar = bar; + } + + public String getBar() { + return bar; + } + + public String getBarFoo() { + return bar; + } + + public String getBarFoo1() { + return bar; + } + + @JsonIgnore + public String getBarHidden() { + return bar; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/OperationEnum.java b/src/test/java/com/hubspot/jinjava/testobjects/OperationEnum.java new file mode 100644 index 000000000..871b333c4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/OperationEnum.java @@ -0,0 +1,18 @@ +package com.hubspot.jinjava.testobjects; + +public enum OperationEnum { + PLUS { + @Override + public int apply(int a, int b) { + return a + b; + } + }, + TIMES { + @Override + public int apply(int a, int b) { + return a * b; + } + }; + + public abstract int apply(int a, int b); +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/PartiallyDeferredValueTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/PartiallyDeferredValueTestObjects.java new file mode 100644 index 000000000..c7c579412 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/PartiallyDeferredValueTestObjects.java @@ -0,0 +1,135 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.PartiallyDeferredValue; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import javax.annotation.CheckForNull; + +public class PartiallyDeferredValueTestObjects { + + public static class BadSerialization implements PartiallyDeferredValue { + + public String getDeferred() { + throw new DeferredValueException("foo.deferred is deferred"); + } + + public String getResolved() { + return "resolved"; + } + + @Override + public Object getOriginalValue() { + return null; + } + } + + public static class BadEntrySet extends PyMap implements PartiallyDeferredValue { + + public BadEntrySet(Map map) { + super(map); + } + + @Override + public Set> entrySet() { + throw new DeferredValueException("entries are deferred"); + } + + @CheckForNull + @Override + public Object get(@CheckForNull Object key) { + if ("deferred".equals(key)) { + throw new DeferredValueException("deferred key"); + } + return super.get(key); + } + + @Override + public Object getOriginalValue() { + return null; + } + } + + public static class BadPyishSerializable + implements PartiallyDeferredValue, PyishSerializable { + + public String getDeferred() { + throw new DeferredValueException("foo.deferred is deferred"); + } + + public String getResolved() { + return "resolved"; + } + + @Override + public Object getOriginalValue() { + return null; + } + + @Override + public T appendPyishString(T appendable) + throws IOException { + throw new DeferredValueException("I'm bad"); + } + } + + public static class GoodPyishSerializable + implements PartiallyDeferredValue, PyishSerializable { + + public String getDeferred() { + throw new DeferredValueException("foo.deferred is deferred"); + } + + public String getResolved() { + return "resolved"; + } + + @Override + public Object getOriginalValue() { + return null; + } + + @Override + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append("good"); + } + } + + public static class BadEntrySetButPyishSerializable + extends PyMap + implements PartiallyDeferredValue, PyishSerializable { + + public BadEntrySetButPyishSerializable(Map map) { + super(map); + } + + @Override + public Set> entrySet() { + throw new DeferredValueException("entries are deferred"); + } + + @CheckForNull + @Override + public Object get(@CheckForNull Object key) { + if ("deferred".equals(key)) { + throw new DeferredValueException("deferred key"); + } + return super.get(key); + } + + @Override + public Object getOriginalValue() { + return null; + } + + @Override + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append("hello"); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/PyishObjectMapperTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/PyishObjectMapperTestObjects.java new file mode 100644 index 000000000..c08c994d4 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/PyishObjectMapperTestObjects.java @@ -0,0 +1,17 @@ +package com.hubspot.jinjava.testobjects; + +public class PyishObjectMapperTestObjects { + + public static class Foo { + + private final String bar; + + public Foo(String bar) { + this.bar = bar; + } + + public String getFooBar() { + return bar; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/SimpleColorEnum.java b/src/test/java/com/hubspot/jinjava/testobjects/SimpleColorEnum.java new file mode 100644 index 000000000..048eccf21 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/SimpleColorEnum.java @@ -0,0 +1,16 @@ +package com.hubspot.jinjava.testobjects; + +public enum SimpleColorEnum { + RED("red-label"), + GREEN("green-label"); + + private final String label; + + SimpleColorEnum(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/SortFilterTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/SortFilterTestObjects.java new file mode 100644 index 000000000..cc985cda0 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/SortFilterTestObjects.java @@ -0,0 +1,58 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; +import java.util.Date; + +public class SortFilterTestObjects { + + public static class MyFoo implements PyishSerializable { + + private Date date; + + public MyFoo(Date date) { + this.date = date; + } + + public Date getDate() { + return date; + } + + @Override + public String toString() { + return "" + date.getTime(); + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } + } + + public static class MyBar implements PyishSerializable { + + private MyFoo foo; + + public MyBar(MyFoo foo) { + this.foo = foo; + } + + public MyFoo getFoo() { + return foo; + } + + @Override + public String toString() { + return foo.toString(); + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/UniqueFilterTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/UniqueFilterTestObjects.java new file mode 100644 index 000000000..034804fc3 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/UniqueFilterTestObjects.java @@ -0,0 +1,32 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.objects.serialization.PyishSerializable; +import java.io.IOException; + +public class UniqueFilterTestObjects { + + public static class MyClass implements PyishSerializable { + + private final String name; + + public MyClass(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "[Name:" + name + "]"; + } + + @Override + @SuppressWarnings("unchecked") + public T appendPyishString(T appendable) + throws IOException { + return (T) appendable.append(toString()); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/testobjects/ValidationModeTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/ValidationModeTestObjects.java new file mode 100644 index 000000000..3aaa9306b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/testobjects/ValidationModeTestObjects.java @@ -0,0 +1,27 @@ +package com.hubspot.jinjava.testobjects; + +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.Filter; + +public class ValidationModeTestObjects { + + public static class ValidationFilter implements Filter { + + private int executionCount = 0; + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + executionCount++; + return var; + } + + public int getExecutionCount() { + return executionCount; + } + + @Override + public String getName() { + return "validation_filter"; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/ExpressionNodeTest.java b/src/test/java/com/hubspot/jinjava/tree/ExpressionNodeTest.java index a7e40950a..ac5273b82 100644 --- a/src/test/java/com/hubspot/jinjava/tree/ExpressionNodeTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/ExpressionNodeTest.java @@ -1,83 +1,357 @@ package com.hubspot.jinjava.tree; +import static com.hubspot.jinjava.lib.expression.DefaultExpressionStrategy.ECHO_UNDEFINED; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.nio.charset.StandardCharsets; - -import org.junit.Before; -import org.junit.Test; - -import com.google.common.base.Throwables; import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; import com.hubspot.jinjava.interpret.JinjavaInterpreter; -import com.hubspot.jinjava.lib.tag.AutoEscapeTag; +import com.hubspot.jinjava.interpret.UnknownTokenException; +import java.nio.charset.StandardCharsets; +import org.junit.Before; +import org.junit.Test; public class ExpressionNodeTest { - private Context context; - private JinjavaInterpreter interpreter; + protected JinjavaInterpreter nestedInterpreter; + protected JinjavaInterpreter interpreter; @Before public void setup() { - interpreter = new Jinjava().newInterpreter(); - context = interpreter.getContext(); + nestedInterpreter = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withNestedInterpretationEnabled(true).build() + ) + .newInterpreter(); + interpreter = + new Jinjava(BaseJinjavaTest.newConfigBuilder().build()).newInterpreter(); } @Test public void itRendersResultAsTemplateWhenContainingVarBlocks() throws Exception { - context.put("myvar", "hello {{ place }}"); - context.put("place", "world"); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {{ place }}"); + nestedInterpreter.getContext().put("place", "world"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()).isEqualTo("hello world"); + } + } - ExpressionNode node = fixture("simplevar"); - assertThat(node.render(interpreter)).isEqualTo("hello world"); + @Test + public void itRendersResultWithNestedExpressionInterpretation() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {{ place }}"); + nestedInterpreter.getContext().put("place", "world"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()).isEqualTo("hello world"); + } + } + + @Test + public void itRendersWithoutNestedExpressionInterpretationByDefault() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + interpreter.getContext().put("myvar", "hello {{ place }}"); + interpreter.getContext().put("place", "world"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(interpreter).toString()).isEqualTo("hello {{ place }}"); + } + } + + @Test + public void itRendersNestedTags() throws Exception { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withNestedInterpretationEnabled(true) + .build(); + try (var a = JinjavaInterpreter.closeablePushCurrent(nestedInterpreter).get()) { + nestedInterpreter + .getContext() + .put("myvar", "hello {% if (true) %}nasty{% endif %}"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()).isEqualTo("hello nasty"); + } } @Test public void itAvoidsInfiniteRecursionWhenVarsContainBraceBlocks() throws Exception { - context.put("myvar", "hello {{ place }}"); - context.put("place", "{{ place }}"); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + interpreter.getContext().put("myvar", "hello {{ place }}"); + interpreter.getContext().put("place", "{{ place }}"); - ExpressionNode node = fixture("simplevar"); - assertThat(node.render(interpreter)).isEqualTo("hello {{ place }}"); + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(interpreter).toString()).isEqualTo("hello {{ place }}"); + } + } + + @Test + public void itAllowsNestedTagExpressions() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "{% if true %}{{ place }}{% endif %}"); + nestedInterpreter.getContext().put("place", "{% if true %}Hello{% endif %}"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()).isEqualTo("Hello"); + } + } + + @Test + public void itAvoidsInfiniteRecursionWhenVarsAreInIfBlocks() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "{% if true %}{{ place }}{% endif %}"); + nestedInterpreter.getContext().put("place", "{% if true %}{{ myvar }}{% endif %}"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()) + .isEqualTo("{% if true %}{{ myvar }}{% endif %}"); + } + } + + @Test + public void itDoesNotRescursivelyEvaluateExpressionsOfSelf() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {{myvar}}"); + + ExpressionNode node = fixture("simplevar"); + // It renders once, and then stop further rendering after detecting recursion. + assertThat(node.render(nestedInterpreter).toString()) + .isEqualTo("hello hello {{myvar}}"); + } + } + + @Test + public void itDoesNotRescursivelyEvaluateExpressions() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {{ place }}"); + nestedInterpreter.getContext().put("place", "{{location}}"); + nestedInterpreter.getContext().put("location", "this is a place."); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()) + .isEqualTo("hello this is a place."); + } + } + + @Test + public void itDoesNotRescursivelyEvaluateMoreExpressions() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {{ place }}"); + nestedInterpreter.getContext().put("place", "there, {{ location }}"); + nestedInterpreter.getContext().put("location", "this is {{ place }}"); + + ExpressionNode node = fixture("simplevar"); + assertThat(node.render(nestedInterpreter).toString()) + .isEqualTo("hello there, this is {{ place }}"); + } + } + + @Test + public void itRendersStringRange() throws Exception { + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + interpreter.getContext().put("theString", "1234567890"); + + ExpressionNode node = fixture("string-range"); + assertThat(node.render(interpreter).toString()).isEqualTo("345"); + } + } + + @Test + public void itRenderEchoUndefined() { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig.newBuilder().add(ECHO_UNDEFINED, FeatureStrategies.ACTIVE).build() + ) + .build(); + try ( + var a = JinjavaInterpreter + .closeablePushCurrent(new Jinjava(config).newInterpreter()) + .get() + ) { + JinjavaInterpreter jinjavaInterpreter = a.value(); + jinjavaInterpreter.getContext().put("subject", "this"); + + String template = + "{{ subject | capitalize() }} expression {{ testing.template('hello_world') }} " + + "has a {{ unknown | lower() }} " + + "token but {{ unknown | default(\"replaced\") }} and empty {{ '' }}"; + Node node = new TreeParser(jinjavaInterpreter, template).buildTree(); + assertThat(jinjavaInterpreter.render(node)) + .isEqualTo( + "This expression {{ testing.template('hello_world') }} " + + "has a {{ unknown | lower() }} token but replaced and empty " + ); + } + } + + @Test + public void itFailsOnUnknownTokensVariables() throws Exception { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFailOnUnknownTokens(true) + .build(); + try ( + var a = JinjavaInterpreter + .closeablePushCurrent(new Jinjava(config).newInterpreter()) + .get() + ) { + JinjavaInterpreter jinjavaInterpreter = a.value(); + String jinja = "{{ UnknownToken }}"; + Node node = new TreeParser(jinjavaInterpreter, jinja).buildTree(); + assertThatThrownBy(() -> jinjavaInterpreter.render(node)) + .isInstanceOf(UnknownTokenException.class) + .hasMessage("Unknown token found: UnknownToken"); + } + } + + @Test + public void itFailsOnUnknownTokensOfLoops() throws Exception { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFailOnUnknownTokens(true) + .build(); + JinjavaInterpreter jinjavaInterpreter = new Jinjava(config).newInterpreter(); + + String jinja = "{% for v in values %} {{ v }} {% endfor %}"; + Node node = new TreeParser(jinjavaInterpreter, jinja).buildTree(); + assertThatThrownBy(() -> jinjavaInterpreter.render(node)) + .isInstanceOf(UnknownTokenException.class) + .hasMessage("Unknown token found: values"); + } + + @Test + public void itFailsOnUnknownTokensOfIf() throws Exception { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFailOnUnknownTokens(true) + .build(); + try ( + var a = JinjavaInterpreter + .closeablePushCurrent(new Jinjava(config).newInterpreter()) + .get() + ) { + JinjavaInterpreter jinjavaInterpreter = a.value(); + String jinja = "{% if bad %} BAD {% endif %}"; + Node node = new TreeParser(jinjavaInterpreter, jinja).buildTree(); + assertThatThrownBy(() -> jinjavaInterpreter.render(node)) + .isInstanceOf(UnknownTokenException.class) + .hasMessageContaining("Unknown token found: bad"); + } + } + + @Test + public void itFailsOnUnknownTokensWithFilter() throws Exception { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFailOnUnknownTokens(true) + .build(); + try ( + var a = JinjavaInterpreter + .closeablePushCurrent(new Jinjava(config).newInterpreter()) + .get() + ) { + JinjavaInterpreter jinjavaInterpreter = a.value(); + String jinja = "{{ UnknownToken }}"; + Node node = new TreeParser(jinjavaInterpreter, jinja).buildTree(); + assertThatThrownBy(() -> jinjavaInterpreter.render(node)) + .isInstanceOf(UnknownTokenException.class) + .hasMessage("Unknown token found: UnknownToken"); + } } @Test public void valueExprWithOr() throws Exception { - context.put("a", "foo"); - context.put("b", "bar"); - context.put("c", ""); - context.put("d", 0); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + interpreter.getContext().put("a", "foo"); + interpreter.getContext().put("b", "bar"); + interpreter.getContext().put("c", ""); + interpreter.getContext().put("d", 0); - assertThat(val("{{ a or b }}")).isEqualTo("foo"); - assertThat(val("{{ c or a }}")).isEqualTo("foo"); - assertThat(val("{{ d or b }}")).isEqualTo("bar"); + assertThat(val("{{ a or b }}")).isEqualTo("foo"); + assertThat(val("{{ c or a }}")).isEqualTo("foo"); + assertThat(val("{{ d or b }}")).isEqualTo("bar"); + } } @Test public void itEscapesValueWhenContextSet() throws Exception { - context.put("a", "foo < bar"); - assertThat(val("{{ a }}")).isEqualTo("foo < bar"); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + interpreter = a.value(); + interpreter.getContext().put("a", "foo < bar"); + assertThat(val("{{ a }}")).isEqualTo("foo < bar"); + + interpreter.getContext().setAutoEscape(true); + assertThat(val("{{ a }}")).isEqualTo("foo < bar"); + } + } - context.put(AutoEscapeTag.AUTOESCAPE_CONTEXT_VAR, Boolean.TRUE); - assertThat(val("{{ a }}")).isEqualTo("foo < bar"); + @Test + public void itIgnoresParseErrorsWhenFeatureIsEnabled() { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + BuiltInFeatures.IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS, + FeatureStrategies.ACTIVE + ) + .build() + ) + .build(); + try (var a = JinjavaInterpreter.closeablePushCurrent(interpreter).get()) { + nestedInterpreter = a.value(); + nestedInterpreter.getContext().put("myvar", "hello {% if"); + nestedInterpreter.getContext().put("place", "world"); + + ExpressionNode node = fixture("simplevar"); + + assertThat(node.render(nestedInterpreter).toString()).isEqualTo("hello {% if"); + assertThat(nestedInterpreter.getErrors()).isEmpty(); + } } private String val(String jinja) { - return parse(jinja).render(interpreter); + return parse(jinja).render(interpreter).getValue(); } private ExpressionNode parse(String jinja) { - return (ExpressionNode) new TreeParser(interpreter, jinja).buildTree().getChildren().getFirst(); + return (ExpressionNode) new TreeParser(interpreter, jinja) + .buildTree() + .getChildren() + .getFirst(); } private ExpressionNode fixture(String name) { try { - return parse(Resources.toString(Resources.getResource(String.format("varblocks/%s.html", name)), StandardCharsets.UTF_8)); + return parse( + Resources.toString( + Resources.getResource(String.format("varblocks/%s.html", name)), + StandardCharsets.UTF_8 + ) + ); } catch (Exception e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/tree/FailOnUnknownTokensTest.java b/src/test/java/com/hubspot/jinjava/tree/FailOnUnknownTokensTest.java new file mode 100644 index 000000000..13042155f --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/tree/FailOnUnknownTokensTest.java @@ -0,0 +1,64 @@ +package com.hubspot.jinjava.tree; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.UnknownTokenException; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class FailOnUnknownTokensTest { + + private static Jinjava jinjava; + + @Before + public void setUp() { + JinjavaConfig.Builder builder = BaseJinjavaTest.newConfigBuilder(); + builder.withFailOnUnknownTokens(true); + JinjavaConfig config = builder.build(); + jinjava = new Jinjava(config); + } + + @Test + public void itReplaceTokensWithoutException() { + Map context = new HashMap<>(); + context.put("token1", "test"); + context.put("token2", "test1"); + String template = "hello {{ token1 }} and {{ token2 }}"; + String renderedTemplate = jinjava.render(template, context); + assertThat(renderedTemplate).isEqualTo("hello test and test1"); + } + + @Test + public void itReplacesTokensWithDefaultValues() { + Map context = new HashMap<>(); + context.put("animal", "lamb"); + context.put("fruit", "apple"); + + String template = + "{{ name | default('mary') }} has a {{ animal }} and eats {{ fruit | default('mango')}}"; + assertThat(jinjava.render(template, context)) + .isEqualTo("mary has a lamb and eats apple"); + } + + @Test + public void itReplacesTokensInContextButThrowsExceptionForOthers() { + final JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFailOnUnknownTokens(true) + .build(); + JinjavaInterpreter jinjavaInterpreter = new Jinjava(config).newInterpreter(); + + String template = "{{ name }} has a {{ animal }}"; + Node node = new TreeParser(jinjavaInterpreter, template).buildTree(); + assertThatThrownBy(() -> jinjavaInterpreter.render(node)) + .isInstanceOf(UnknownTokenException.class) + .hasMessageContaining("Unknown token found: name"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/TreeParserTest.java b/src/test/java/com/hubspot/jinjava/tree/TreeParserTest.java index b713d8d68..7beba0733 100644 --- a/src/test/java/com/hubspot/jinjava/tree/TreeParserTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/TreeParserTest.java @@ -2,49 +2,415 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context.TemporaryValueClosable; +import com.hubspot.jinjava.interpret.ErrorHandlingStrategy; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; import java.nio.charset.StandardCharsets; - -import org.junit.Before; import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.io.Resources; -import com.hubspot.jinjava.Jinjava; -import com.hubspot.jinjava.JinjavaConfig; -import com.hubspot.jinjava.interpret.JinjavaInterpreter; +public class TreeParserTest extends BaseInterpretingTest { -public class TreeParserTest { + @Test + public void parseHtmlWithCommentLines() { + parse("parse/tokenizer/comment-plus.jinja"); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } - JinjavaInterpreter interpreter; + @Test + public void itStripsRightWhiteSpace() throws Exception { + String expression = "{% for foo in [1,2,3] -%} \n .{{ foo }}\n{% endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".1\n.2\n.3\n"); + } - @Before - public void setup() { - interpreter = new Jinjava().newInterpreter(); + @Test + public void itStripsRightWhiteSpaceWithComment() throws Exception { + String expression = + "{% for foo in [1,2,3] -%} \n {#- comment -#} \n {#- comment -#} .{{ foo }}\n{% endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".1\n.2\n.3\n"); } @Test - public void parseHtmlWithCommentLines() { - parse("parse/tokenizer/comment-plus.jinja"); - assertThat(interpreter.getErrors()).isEmpty(); + public void itStripsLeftWhiteSpace() throws Exception { + String expression = "{% for foo in [1,2,3] %}\n{{ foo }}. \n {%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("\n1.\n2.\n3."); + } + + @Test + public void itStripsLeftWhiteSpaceWithComment() throws Exception { + String expression = + "{% for foo in [1,2,3] %}\n{{ foo }}. \n {#- comment -#} {%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("\n1.\n2.\n3."); + } + + @Test + public void itStripsLeftAndRightWhiteSpace() throws Exception { + String expression = "{% for foo in [1,2,3] -%} \n .{{ foo }}. \n {%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".1..2..3."); + } + + @Test + public void itStripsLeftAndRightWhiteSpaceWithComment() throws Exception { + String expression = + "{% for foo in [1,2,3] -%} \n {#- comment -#} \n {#- comment -#} .{{ foo }}. \n {#- comment -#} \n {#- comment -#} {%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".1..2..3."); + } + + @Test + public void itPreservesInnerWhiteSpace() throws Exception { + String expression = + "{% for foo in [1,2,3] -%}\nL{% if true %}\n{{ foo }}\n{% endif %}R\n{%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("L\n1\nRL\n2\nRL\n3\nR"); + } + + @Test + public void itPreservesInnerWhiteSpaceWithComment() throws Exception { + String expression = + "{% for foo in [1,2,3] -%}\n {#- comment -#} \n {#- comment -#}L{% if true %}\n{{ foo }}\n{% endif %}R\n {#- comment -#} \n {#- comment -#}{%- endfor %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("L\n1\nRL\n2\nRL\n3\nR"); + } + + @Test + public void itStripsLeftWhiteSpaceBeforeTag() throws Exception { + String expression = ".\n {%- for foo in [1,2,3] %} {{ foo }} {% endfor %} \n."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(". 1 2 3 \n."); + } + + @Test + public void itStripsLeftWhiteSpaceBeforeTagWithComment() throws Exception { + String expression = + ".\n {#- comment -#} \n {#- comment -#} {%- for foo in [1,2,3] %} {{ foo }} {% endfor %} \n."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(". 1 2 3 \n."); + } + + @Test + public void itStripsRightWhiteSpaceAfterTag() throws Exception { + String expression = ".\n {% for foo in [1,2,3] %} {{ foo }} {% endfor -%} \n."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".\n 1 2 3 ."); + } + + @Test + public void itStripsRightWhiteSpaceAfterTagWithComment() throws Exception { + String expression = + ".\n {% for foo in [1,2,3] %} {{ foo }} {% endfor -%} \n {#- comment -#} \n {#- comment -#}."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".\n 1 2 3 ."); + } + + @Test + public void itStripsAllOuterWhiteSpace() throws Exception { + String expression = ".\n {%- for foo in [1,2,3] -%} {{ foo }} {%- endfor -%} \n."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".123."); + } + + @Test + public void itStripsAllOuterWhiteSpaceForInlineTags() throws Exception { + String expression = "1\n\n{%- print 2 -%}\n\n3\n\n{%- set x = 1 -%}\n\n4"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("1234"); + } + + @Test + public void itStripsAllOuterWhiteSpaceWithComment() throws Exception { + String expression = + ".\n {#- comment -#} \n {#- comment -#} {%- for foo in [1,2,3] -%} {{ foo }} {%- endfor -%} \n {#- comment -#} \n {#- comment -#}."; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo(".123."); } @Test public void trimAndLstripBlocks() { - interpreter = new Jinjava(JinjavaConfig.newBuilder().withLstripBlocks(true).withTrimBlocks(true).build()).newInterpreter(); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLstripBlocks(true) + .withTrimBlocks(true) + .build() + ) + .newInterpreter(); assertThat(interpreter.render(parse("parse/tokenizer/whitespace-tags.jinja"))) - .isEqualTo("
    \n" - + " yay\n" - + "
    \n"); + .isEqualTo("
    \n" + " yay\n" + "
    \n"); + } + + @Test + public void trimAndLstripCommentBlocks() { + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLstripBlocks(true) + .withTrimBlocks(true) + .build() + ) + .newInterpreter(); + + assertThat(interpreter.render(parse("parse/tokenizer/whitespace-comment-tags.jinja"))) + .isEqualTo("
    \n" + " yay\n" + " whoop\n" + "
    \n"); + } + + @Test + public void itWarnsAgainstMissingStartTags() { + String expression = "{% if true %} foo {% endif %} {% endif %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.WARNING); + assertThat(interpreter.getErrors().get(0).getMessage()) + .isEqualTo("Missing start tag"); + assertThat(interpreter.getErrors().get(0).getFieldName()).isEqualTo("endif"); + } + + @Test + public void itWarnsAgainstUnclosedComment() { + String expression = "foo {# this is an unclosed comment %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.WARNING); + assertThat(interpreter.getErrors().get(0).getMessage()).isEqualTo("Unclosed comment"); + assertThat(interpreter.getErrors().get(0).getFieldName()).isEqualTo("comment"); + } + + @Test + public void itWarnsAgainstUnclosedExpression() { + String expression = "foo {{ this is an unclosed expression %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.WARNING); + assertThat(interpreter.getErrors().get(0).getMessage()).isEqualTo("Unclosed token"); + assertThat(interpreter.getErrors().get(0).getFieldName()).isEqualTo("token"); + assertThat(interpreter.render(tree)) + .isEqualTo("foo {{ this is an unclosed expression %}"); + } + + @Test + public void itWarnsAgainstUnclosedTag() { + String expression = "foo {% this is an unclosed tag }}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(1); + assertThat(interpreter.getErrors().get(0).getSeverity()).isEqualTo(ErrorType.WARNING); + assertThat(interpreter.getErrors().get(0).getMessage()).isEqualTo("Unclosed token"); + assertThat(interpreter.getErrors().get(0).getFieldName()).isEqualTo("token"); + assertThat(interpreter.render(tree)).isEqualTo("foo {% this is an unclosed tag }}"); + } + + @Test + public void itWarnsTwiceAgainstUnclosedForTag() { + String expression = "{% for item in list %}\n{% for elem in items %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(2); + assertThat(interpreter.getErrors().get(0).getFieldName()) + .isEqualTo("{% for elem in items %}"); + assertThat(interpreter.getErrors().get(0).getLineno()).isEqualTo(2); + assertThat(interpreter.getErrors().get(1).getFieldName()) + .isEqualTo("{% for item in list %}"); + assertThat(interpreter.getErrors().get(1).getLineno()).isEqualTo(1); + } + + @Test + public void itWarnsTwiceAgainstUnclosedIfTag() { + String expression = "{% if 1 > 2 %}\n{% if 2 > 1 %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(2); + assertThat(interpreter.getErrors().get(0).getFieldName()).isEqualTo("{% if 2 > 1 %}"); + assertThat(interpreter.getErrors().get(0).getLineno()).isEqualTo(2); + assertThat(interpreter.getErrors().get(1).getFieldName()).isEqualTo("{% if 1 > 2 %}"); + assertThat(interpreter.getErrors().get(1).getLineno()).isEqualTo(1); + } + + @Test + public void itWarnsTwiceAgainstUnclosedBlockTag() { + String expression = "{% block first %}\n{% block second %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.getErrors()).hasSize(2); + assertThat(interpreter.getErrors().get(0).getFieldName()) + .isEqualTo("{% block second %}"); + assertThat(interpreter.getErrors().get(0).getLineno()).isEqualTo(2); + assertThat(interpreter.getErrors().get(1).getFieldName()) + .isEqualTo("{% block first %}"); + assertThat(interpreter.getErrors().get(1).getLineno()).isEqualTo(1); + } + + @Test + public void itTrimsNotes() { + String expression = "A\n{#- note -#}\nB"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("AB"); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withUseTrimmingForNotesAndExpressions(false) + .build() + ) + .build() + ) + .newInterpreter(); + final Node newTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(newTree)).isEqualTo("A\n\nB"); + } + + @Test + public void itAllowsTrailingNote() { + String expression = "A\n{# "; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("A\n"); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides + .newBuilder() + .withUseTrimmingForNotesAndExpressions(true) + .build() + ) + .build() + ) + .newInterpreter(); + final Node newTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(newTree)).isEqualTo("A\n"); + } + + @Test + public void itAllowsTrailingExpression() { + String expression = "A\n{{ "; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("A\n{{ "); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides + .newBuilder() + .withUseTrimmingForNotesAndExpressions(true) + .build() + ) + .build() + ) + .newInterpreter(); + final Node newTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(newTree)).isEqualTo("A\n{{ "); + } + + @Test + public void itAllowsTrailingTag() { + String expression = "A\n{% "; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("A\n{% "); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides + .newBuilder() + .withUseTrimmingForNotesAndExpressions(true) + .build() + ) + .build() + ) + .newInterpreter(); + final Node newTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(newTree)).isEqualTo("A\n{% "); + } + + @Test + public void itMergesTextNodesWhileRespectingTrim() { + String expression = "{% print 'A' -%}\n{#- note -#}\nB\n{%- print 'C' %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("ABC"); + } + + @Test + public void itTrimsExpressions() { + String expression = "A\n{{- 'B' -}}\nC"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("ABC"); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withUseTrimmingForNotesAndExpressions(false) + .build() + ) + .build() + ) + .newInterpreter(); + final Node newTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(newTree)).isEqualTo("A\nB\nC"); + } + + @Test + public void itDoesNotMergeAdjacentTextNodesWhenLegacyOverrideIsApplied() { + String expression = "A\n{%- if true -%}\n{# comment #}\nB{% endif %}"; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(tree)).isEqualTo("A\nB"); + interpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.Builder + .from(LegacyOverrides.THREE_POINT_0) + .withAllowAdjacentTextNodes(false) + .build() + ) + .build() + ) + .newInterpreter(); + final Node overriddenTree = new TreeParser(interpreter, expression).buildTree(); + assertThat(interpreter.render(overriddenTree)).isEqualTo("AB"); + } + + @Test + public void itDoesNotAddErrorWhenParseErrorsAreIgnored() { + try ( + TemporaryValueClosable c = interpreter + .getContext() + .withErrorHandlingStrategy(ErrorHandlingStrategy.ignoreAll()) + ) { + String expression = "{% if "; + final Node tree = new TreeParser(interpreter, expression).buildTree(); + assertThat(tree.getChildren()).hasSize(1); + assertThat(tree.getChildren().get(0).toTreeString()) + .isEqualToIgnoringWhitespace(" {~ {% if ~}"); + } + assertThat(interpreter.getErrors()).isEmpty(); } Node parse(String fixture) { try { - return new TreeParser(interpreter, Resources.toString( - Resources.getResource(fixture), StandardCharsets.UTF_8)).buildTree(); + return new TreeParser( + interpreter, + Resources.toString(Resources.getResource(fixture), StandardCharsets.UTF_8) + ) + .buildTree(); } catch (Exception e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/BackslashHandlingTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/BackslashHandlingTest.java new file mode 100644 index 000000000..0515e4e10 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/tree/parse/BackslashHandlingTest.java @@ -0,0 +1,234 @@ +package com.hubspot.jinjava.tree.parse; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.AbstractIterator; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import org.junit.Test; + +/** + * Tests for backslash handling inside block/variable/comment delimiters, + * covering both the char-based (DefaultTokenScannerSymbols) and string-based + * (StringTokenScannerSymbols) scanning paths, with the + * {@link LegacyOverrides#isHandleBackslashInQuotesOnly()} flag both off (legacy) + * and on (Jinja2-compatible). + */ +public class BackslashHandlingTest { + + // ── Jinjava instances ────────────────────────────────────────────────────── + + /** Char-based scanner, legacy backslash behaviour (flag = false). */ + private static Jinjava charLegacy() { + return new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides(LegacyOverrides.newBuilder().build()) + .build() + ); + } + + /** Char-based scanner, Jinja2-compatible backslash behaviour (flag = true). */ + private static Jinjava charNew() { + return new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withHandleBackslashInQuotesOnly(true).build() + ) + .build() + ); + } + + /** String-based scanner, legacy backslash behaviour (flag = false). */ + private static Jinjava stringLegacy() { + return new Jinjava( + JinjavaConfig + .newBuilder() + .withTokenScannerSymbols(StringTokenScannerSymbols.builder().build()) + .withLegacyOverrides(LegacyOverrides.newBuilder().build()) + .build() + ); + } + + /** String-based scanner, Jinja2-compatible backslash behaviour (flag = true). */ + private static Jinjava stringNew() { + return new Jinjava( + JinjavaConfig + .newBuilder() + .withTokenScannerSymbols(StringTokenScannerSymbols.builder().build()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withHandleBackslashInQuotesOnly(true).build() + ) + .build() + ); + } + + // ── Backslash inside a quoted string ────────────────────────────────────── + // + // Both legacy and new behaviour must handle escaped quotes inside strings + // correctly — \" should not close the string. + + @Test + public void charLegacy_escapedQuoteInsideString() { + assertThat(charLegacy().render("{{ \"he said \\\"hi\\\"\" }}", new HashMap<>())) + .isEqualTo("he said \"hi\""); + } + + @Test + public void charNew_escapedQuoteInsideString() { + assertThat(charNew().render("{{ \"he said \\\"hi\\\"\" }}", new HashMap<>())) + .isEqualTo("he said \"hi\""); + } + + @Test + public void stringLegacy_escapedQuoteInsideString() { + assertThat(stringLegacy().render("{{ \"he said \\\"hi\\\"\" }}", new HashMap<>())) + .isEqualTo("he said \"hi\""); + } + + @Test + public void stringNew_escapedQuoteInsideString() { + assertThat(stringNew().render("{{ \"he said \\\"hi\\\"\" }}", new HashMap<>())) + .isEqualTo("he said \"hi\""); + } + + // ── Backslash outside a quoted string ───────────────────────────────────── + // + // Template under test: "prefix {{ x \}} suffix }}" + // + // We test the scanner token structure directly rather than going through + // render(), because the expression "x \..." is always a JUEL lexical error + // regardless of mode. What differs between modes is which token boundaries + // the scanner produces — and that is what we assert on. + // + // Legacy (backslashInQuotesOnly = false): + // Scanner consumes '\' and skips the following '}'. The first '}}' is not + // recognized as a closer. The block runs until the second '}}', so the + // token sequence is: + // TEXT "prefix " | EXPR "{{ x \}} suffix }}" + // + // New (backslashInQuotesOnly = true): + // Scanner leaves '\' untouched. The first '}}' is recognized as the closer. + // The token sequence is: + // TEXT "prefix " | EXPR "{{ x \}}" | TEXT " suffix }}" + + private static final String BACKSLASH_TEMPLATE = "prefix {{ x \\}} suffix }}"; + + @Test + public void charLegacy_backslashConsumesOneDelimiterChar_blockRunsToSecondCloser() { + List tokens = scanAll( + new TokenScanner(BACKSLASH_TEMPLATE, charLegacy().getGlobalConfig()) + ); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0)).isInstanceOf(TextToken.class); + assertThat(tokens.get(0).image).isEqualTo("prefix "); + assertThat(tokens.get(1)).isInstanceOf(ExpressionToken.class); + assertThat(tokens.get(1).image).isEqualTo("{{ x \\}} suffix }}"); + } + + @Test + public void charNew_backslashIgnored_blockClosesAtFirstDelimiter() { + List tokens = scanAll( + new TokenScanner(BACKSLASH_TEMPLATE, charNew().getGlobalConfig()) + ); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0)).isInstanceOf(TextToken.class); + assertThat(tokens.get(0).image).isEqualTo("prefix "); + assertThat(tokens.get(1)).isInstanceOf(ExpressionToken.class); + assertThat(tokens.get(1).image).isEqualTo("{{ x \\}}"); + assertThat(tokens.get(2)).isInstanceOf(TextToken.class); + assertThat(tokens.get(2).image).isEqualTo(" suffix }}"); + } + + @Test + public void stringLegacy_backslashConsumesOneDelimiterChar_blockRunsToSecondCloser() { + List tokens = scanAll( + new StringTokenScanner(BACKSLASH_TEMPLATE, stringLegacy().getGlobalConfig()) + ); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0)).isInstanceOf(TextToken.class); + assertThat(tokens.get(0).image).isEqualTo("prefix "); + assertThat(tokens.get(1)).isInstanceOf(ExpressionToken.class); + assertThat(tokens.get(1).image).isEqualTo("{{ x \\}} suffix }}"); + } + + @Test + public void stringNew_backslashIgnored_blockClosesAtFirstDelimiter() { + List tokens = scanAll( + new StringTokenScanner(BACKSLASH_TEMPLATE, stringNew().getGlobalConfig()) + ); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0)).isInstanceOf(TextToken.class); + assertThat(tokens.get(0).image).isEqualTo("prefix "); + assertThat(tokens.get(1)).isInstanceOf(ExpressionToken.class); + assertThat(tokens.get(1).image).isEqualTo("{{ x \\}}"); + assertThat(tokens.get(2)).isInstanceOf(TextToken.class); + assertThat(tokens.get(2).image).isEqualTo(" suffix }}"); + } + + private static List scanAll(AbstractIterator scanner) { + List tokens = new ArrayList<>(); + scanner.forEachRemaining(tokens::add); + return tokens; + } + + // ── Backslash in a plain variable expression ─────────────────────────────── + // + // The most common real-world case: a Windows path or similar string passed + // directly as a variable value. The backslash is in the *value*, not the + // template, so scanner behaviour is irrelevant — both modes should render + // identically. + + @Test + public void backslashInVariableValueIsUnaffectedByFlag_char() { + ImmutableMap ctx = ImmutableMap.of("path", "C:\\Users\\foo"); + assertThat(charLegacy().render("{{ path }}", ctx)).isEqualTo("C:\\Users\\foo"); + assertThat(charNew().render("{{ path }}", ctx)).isEqualTo("C:\\Users\\foo"); + } + + @Test + public void backslashInVariableValueIsUnaffectedByFlag_string() { + ImmutableMap ctx = ImmutableMap.of("path", "C:\\Users\\foo"); + assertThat(stringLegacy().render("{{ path }}", ctx)).isEqualTo("C:\\Users\\foo"); + assertThat(stringNew().render("{{ path }}", ctx)).isEqualTo("C:\\Users\\foo"); + } + + // ── New behaviour: simple expressions are unaffected ────────────────────── + // + // Expressions with no backslash should behave identically under both modes. + + @Test + public void charNew_simpleExpressionUnchanged() { + assertThat(charNew().render("{{ greeting }}", ImmutableMap.of("greeting", "hello"))) + .isEqualTo("hello"); + } + + @Test + public void stringNew_simpleExpressionUnchanged() { + assertThat(stringNew().render("{{ greeting }}", ImmutableMap.of("greeting", "hello"))) + .isEqualTo("hello"); + } + + // ── LegacyOverrides preset assertions ───────────────────────────────────── + + @Test + public void allPresetDoesNotEnableNewBackslashHandling() { + assertThat(LegacyOverrides.ALL.isHandleBackslashInQuotesOnly()).isTrue(); + } + + @Test + public void threePointZeroPresetDoesNotEnableNewBackslashHandling() { + assertThat(LegacyOverrides.THREE_POINT_0.isHandleBackslashInQuotesOnly()).isTrue(); + } + + @Test + public void nonePresetKeepsLegacyBackslashHandling() { + assertThat(LegacyOverrides.NONE.isHandleBackslashInQuotesOnly()).isFalse(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/CustomTokenScannerSymbolsTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/CustomTokenScannerSymbolsTest.java new file mode 100644 index 000000000..7feb78f85 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/tree/parse/CustomTokenScannerSymbolsTest.java @@ -0,0 +1,110 @@ +package com.hubspot.jinjava.tree.parse; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.lib.filter.JoinFilterTest.User; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class CustomTokenScannerSymbolsTest { + + private Jinjava jinjava; + private JinjavaConfig config; + + @Before + public void setup() { + config = + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols(new CustomTokens()) + .build(); + jinjava = new Jinjava(config); + jinjava.getGlobalContext().put("numbers", Lists.newArrayList(1L, 2L, 3L, 4L, 5L)); + } + + @Test + public void itRendersWithCustomTokens() { + String template = "jinjava interpreter works correctly"; + assertThat(jinjava.render(template, new HashMap())) + .isEqualTo(template); + } + + @Test + public void itRendersFiltersWithCustomTokens() { + assertThat( + jinjava.render( + "<% set d=d | default(\"some random value\") %><< d >>", + new HashMap<>() + ) + ) + .isEqualTo("some random value"); + assertThat(jinjava.render("<< [1, 2, 3, 3]|union(null) >>", new HashMap<>())) + .isEqualTo("[1, 2, 3]"); + assertThat(jinjava.render("<< numbers|select('equalto', 3) >>", new HashMap<>())) + .isEqualTo("[3]"); + assertThat( + jinjava.render( + "<< users|map(attribute='username')|join(', ') >>", + ImmutableMap.of( + "users", + (Object) Lists.newArrayList(new User("foo"), new User("bar")) + ) + ) + ) + .isEqualTo("foo, bar"); + } + + class CustomTokens extends TokenScannerSymbols { + + @Override + public char getPrefixChar() { + return '<'; + } + + @Override + public char getPostfixChar() { + return '>'; + } + + @Override + public char getFixedChar() { + return 0; + } + + @Override + public char getNoteChar() { + return '#'; + } + + @Override + public char getTagChar() { + return '%'; + } + + @Override + public char getExprStartChar() { + return '<'; + } + + @Override + public char getExprEndChar() { + return '>'; + } + + @Override + public char getNewlineChar() { + return '\n'; + } + + @Override + public char getTrimChar() { + return '-'; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParserTest b/src/test/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParserTest new file mode 100644 index 000000000..b0343e287 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/tree/parse/StrictWhitespaceControlParserTest @@ -0,0 +1,48 @@ +package com.hubspot.jinjava.tree.parse; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Before; +import org.junit.Test; + +public class StrictWhitespaceControlParserTest { + + StrictWhitespaceControlParser parser; + + @Before + public void setUp() { + parser = new StrictWhitespaceControlParser(); + } + + @Test + public void itDoesNotTrimEmptyTokenOnLeft() { + assertThat(parser.hasLeftTrim("")).isFalse(); + } + + @Test + public void itDoesNotTrimEmptyTokenOnRight() { + assertThat(parser.hasRightTrim("")).isFalse(); + } + + @Test + public void itStripsLeftOfEmptyTokenWithoutThrowing() { + assertThat(parser.stripLeft("")).isEqualTo(""); + } + + @Test + public void itStripsRightOfEmptyTokenWithoutThrowing() { + assertThat(parser.stripRight("")).isEqualTo(""); + } + + @Test + public void itDetectsLeftTrim() { + assertThat(parser.hasLeftTrim("-foo")).isTrue(); + assertThat(parser.stripLeft("-foo")).isEqualTo("foo"); + } + + @Test + public void itDetectsRightTrim() { + assertThat(parser.hasRightTrim("foo-")).isTrue(); + assertThat(parser.stripRight("foo-")).isEqualTo("foo"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java new file mode 100644 index 000000000..b7af6c074 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java @@ -0,0 +1,517 @@ +package com.hubspot.jinjava.tree.parse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.lib.filter.JoinFilterTest.User; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; + +public class StringTokenScannerSymbolsTest { + + // ── Shared symbol configurations ─────────────────────────────────────────── + + /** LaTeX-style delimiters as used in the original issue #195 example. */ + private static final StringTokenScannerSymbols LATEX_SYMBOLS = StringTokenScannerSymbols + .builder() + .withVariableStartString("\\VAR{") + .withVariableEndString("}") + .withBlockStartString("\\BLOCK{") + .withBlockEndString("}") + .withCommentStartString("\\#{") + .withCommentEndString("}") + .build(); + + /** Angle-bracket style — same delimiters as the existing CustomTokenScannerSymbolsTest. */ + private static final StringTokenScannerSymbols ANGLE_SYMBOLS = StringTokenScannerSymbols + .builder() + .withVariableStartString("<<") + .withVariableEndString(">>") + .withBlockStartString("<%") + .withBlockEndString("%>") + .withCommentStartString("<#") + .withCommentEndString("#>") + .build(); + + private Jinjava latexJinjava; + private Jinjava angleJinjava; + + @Before + public void setup() { + latexJinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withTokenScannerSymbols(LATEX_SYMBOLS).build() + ); + latexJinjava + .getGlobalContext() + .put("numbers", Lists.newArrayList(1L, 2L, 3L, 4L, 5L)); + + angleJinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withTokenScannerSymbols(ANGLE_SYMBOLS).build() + ); + angleJinjava + .getGlobalContext() + .put("numbers", Lists.newArrayList(1L, 2L, 3L, 4L, 5L)); + } + + // ── Plain text ───────────────────────────────────────────────────────────── + + @Test + public void itRendersPlainText() { + String template = "jinjava interpreter works correctly"; + assertThat(latexJinjava.render(template, new HashMap<>())).isEqualTo(template); + assertThat(angleJinjava.render(template, new HashMap<>())).isEqualTo(template); + } + + // ── Variable expressions ─────────────────────────────────────────────────── + + @Test + public void itRendersVariablesWithLatexSymbols() { + assertThat(latexJinjava.render("\\VAR{ name }", ImmutableMap.of("name", "World"))) + .isEqualTo("World"); + } + + @Test + public void itRendersVariablesWithAngleSymbols() { + assertThat(angleJinjava.render("<< name >>", ImmutableMap.of("name", "World"))) + .isEqualTo("World"); + } + + // ── Default delimiters pass through as literal text ──────────────────────── + + @Test + public void itPassesThroughDefaultCurlyBracesAsLiteralText() { + // With custom delimiters, {{ }} must be treated as plain text, not expressions. + assertThat( + latexJinjava.render( + "{{ not a variable }} \\VAR{ name }", + ImmutableMap.of("name", "Jorge") + ) + ) + .isEqualTo("{{ not a variable }} Jorge"); + + assertThat( + angleJinjava.render( + "{{ not a variable }} << name >>", + ImmutableMap.of("name", "Jorge") + ) + ) + .isEqualTo("{{ not a variable }} Jorge"); + } + + // ── Block tags ───────────────────────────────────────────────────────────── + + @Test + public void itRendersIfBlockWithLatexSymbols() { + assertThat( + latexJinjava.render( + "\\BLOCK{ if show }hello\\BLOCK{ endif }", + ImmutableMap.of("show", true) + ) + ) + .isEqualTo("hello"); + + assertThat( + latexJinjava.render( + "\\BLOCK{ if show }hello\\BLOCK{ endif }", + ImmutableMap.of("show", false) + ) + ) + .isEqualTo(""); + } + + @Test + public void itRendersSetBlockWithAngleSymbols() { + assertThat( + angleJinjava.render( + "<% set d=d | default(\"some random value\") %><< d >>", + new HashMap<>() + ) + ) + .isEqualTo("some random value"); + } + + // ── Comments ─────────────────────────────────────────────────────────────── + + @Test + public void itStripsCommentsWithLatexSymbols() { + assertThat(latexJinjava.render("before\\#{ this is ignored }after", new HashMap<>())) + .isEqualTo("beforeafter"); + } + + @Test + public void itStripsCommentsWithAngleSymbols() { + assertThat(angleJinjava.render("before<# this is ignored #>after", new HashMap<>())) + .isEqualTo("beforeafter"); + } + + // ── Filters ──────────────────────────────────────────────────────────────── + + @Test + public void itRendersFiltersWithLatexSymbols() { + assertThat(latexJinjava.render("\\VAR{ [1, 2, 3, 3]|union(null) }", new HashMap<>())) + .isEqualTo("[1, 2, 3]"); + assertThat( + latexJinjava.render("\\VAR{ numbers|select('equalto', 3) }", new HashMap<>()) + ) + .isEqualTo("[3]"); + } + + @Test + public void itRendersFiltersWithAngleSymbols() { + assertThat(angleJinjava.render("<< [1, 2, 3, 3]|union(null) >>", new HashMap<>())) + .isEqualTo("[1, 2, 3]"); + assertThat(angleJinjava.render("<< numbers|select('equalto', 3) >>", new HashMap<>())) + .isEqualTo("[3]"); + } + + @Test + public void itRendersMapFilterWithLatexSymbols() { + assertThat( + latexJinjava.render( + "\\VAR{ users|map(attribute='username')|join(', ') }", + ImmutableMap.of( + "users", + (Object) Lists.newArrayList(new User("foo"), new User("bar")) + ) + ) + ) + .isEqualTo("foo, bar"); + } + + @Test + public void itRendersMapFilterWithAngleSymbols() { + assertThat( + angleJinjava.render( + "<< users|map(attribute='username')|join(', ') >>", + ImmutableMap.of( + "users", + (Object) Lists.newArrayList(new User("foo"), new User("bar")) + ) + ) + ) + .isEqualTo("foo, bar"); + } + + // ── Delimiter characters inside string literals in expressions ───────────── + + @Test + public void itHandlesClosingDelimiterInsideQuotedString() { + // The "}" inside the default string must not prematurely close \VAR{ + assertThat(latexJinjava.render("\\VAR{ name | default(\"}\") }", new HashMap<>())) + .isEqualTo("}"); + } + + @Test + public void itHandlesClosingDelimiterInsideQuotedStringAngle() { + // ">>" inside a quoted string must not close the << expression + assertThat(angleJinjava.render("<< name | default(\">>\") >>", new HashMap<>())) + .isEqualTo(">>"); + } + + // ── Builder defaults produce same behaviour as DefaultTokenScannerSymbols ── + + @Test + public void defaultBuilderBehavesLikeDefaultSymbols() { + Jinjava defaultJinjava = new Jinjava(); + Jinjava stringBasedDefaultJinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withTokenScannerSymbols(StringTokenScannerSymbols.builder().build()) + .build() + ); + String template = "{{ greeting }}, {{ name }}!"; + ImmutableMap ctx = ImmutableMap.of( + "greeting", + "Hello", + "name", + "World" + ); + assertThat(stringBasedDefaultJinjava.render(template, ctx)) + .isEqualTo(defaultJinjava.render(template, ctx)); + } + + // ── trimBlocks and lstripBlocks ──────────────────────────────────────────── + // + // trimBlocks is handled in TokenScanner.emitStringToken(): when a TagToken or + // NoteToken is emitted and trimBlocks=true, the immediately following newline + // is consumed. This is equally true in the string-based path. + // + // lstripBlocks is handled in TreeParser, which operates on the token stream + // produced by TokenScanner. It strips leading horizontal whitespace from any + // TextNode that immediately precedes a TagNode. Since TreeParser is path-agnostic, + // lstripBlocks works identically for both char-based and string-based scanning. + + @Test + public void itRespectsTrimBlocksWithAngleSymbols() { + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols(ANGLE_SYMBOLS) + .withTrimBlocks(true) + .withKeepTrailingNewline(true) + .build() + ); + // Without trimBlocks the newline after <% if show %> would appear in output. + // With trimBlocks=true it is consumed by the scanner, so output is "hello". + String result = j.render( + "<% if show %>\nhello\n<% endif %>", + ImmutableMap.of("show", true) + ); + assertThat(result).isEqualTo("hello\n"); + } + + @Test + public void itRespectsTrimBlocksWithLatexSymbols() { + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols(LATEX_SYMBOLS) + .withTrimBlocks(true) + .withKeepTrailingNewline(true) + .build() + ); + String result = j.render( + "\\BLOCK{ if show }\nhello\n\\BLOCK{ endif }", + ImmutableMap.of("show", true) + ); + assertThat(result).isEqualTo("hello\n"); + } + + @Test + public void itRespectsLstripBlocksWithAngleSymbols() { + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols(ANGLE_SYMBOLS) + .withLstripBlocks(true) + .withTrimBlocks(true) + .withKeepTrailingNewline(true) + .build() + ); + // Leading spaces before the tag are stripped by lstripBlocks (TreeParser). + // The newline after the tag is consumed by trimBlocks (TokenScanner). + String result = j.render( + " <% if show %>\nhello\n <% endif %>", + ImmutableMap.of("show", true) + ); + assertThat(result).isEqualTo("hello\n"); + } + + @Test + public void itRespectsLstripBlocksWithLatexSymbols() { + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols(LATEX_SYMBOLS) + .withLstripBlocks(true) + .withTrimBlocks(true) + .withKeepTrailingNewline(true) + .build() + ); + String result = j.render( + " \\BLOCK{ if show }\nhello\n \\BLOCK{ endif }", + ImmutableMap.of("show", true) + ); + assertThat(result).isEqualTo("hello\n"); + } + + @Test + public void builderRejectsEmptyDelimiter() { + assertThatThrownBy(() -> + StringTokenScannerSymbols.builder().withVariableStartString("").build() + ) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void builderRejectsNullDelimiter() { + assertThatThrownBy(() -> + StringTokenScannerSymbols.builder().withBlockEndString(null).build() + ) + .isInstanceOf(IllegalArgumentException.class); + } + + // ── Line statement prefix ────────────────────────────────────────────────── + + @Test + public void itRendersLineStatementPrefix() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols.builder().withLineStatementPrefix("%%").build() + ); + // "%% if show" is equivalent to "{% if show %}" + String template = "%% if show\nhello\n%% endif"; + assertThat(j.render(template, ImmutableMap.of("show", true))).isEqualTo("hello\n"); + assertThat(j.render(template, ImmutableMap.of("show", false))).isEqualTo(""); + } + + @Test + public void itRendersLineStatementPrefixWithWhitespaceControl() { + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols( + StringTokenScannerSymbols.builder().withLineStatementPrefix("%%").build() + ) + .withTrimBlocks(true) + .withLstripBlocks(true) + .build() + ); + // "%%- for" strips the newline before the line (leftTrim). + // trimBlocks consumes the newline after each tag line. + // Expected: the \n after {| is stripped, c| repeated col_num times, each + // followed by \n (from the body line), with the \n after c| stripped by + // the leftTrim on %%- endfor. + String template = "before|\n%%- for _ in range(3)\nc|\n%%- endfor\nafter"; + assertThat(j.render(template, ImmutableMap.of())).isEqualTo("before|c|c|c|after"); + } + + @Test + public void itRendersLineStatementPrefixWithLeadingWhitespace() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols.builder().withLineStatementPrefix("%%").build() + ); + // Leading spaces before the prefix are allowed + String template = " %% if show\nhello\n %% endif"; + assertThat(j.render(template, ImmutableMap.of("show", true))).isEqualTo("hello\n"); + } + + @Test + public void itRendersLineStatementMixedWithBlockDelimiters() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols + .builder() + .withVariableStartString("<<") + .withVariableEndString(">>") + .withBlockStartString("<%") + .withBlockEndString("%>") + .withCommentStartString("<#") + .withCommentEndString("#>") + .withLineStatementPrefix("%%") + .build() + ); + String template = "%% set x = 42\n<< x >>"; + assertThat(j.render(template, new HashMap<>())).isEqualTo("42"); + } + + // ── Line comment prefix ──────────────────────────────────────────────────── + // + // Ground truth confirmed by running both Python Jinja2 and Jinjava against: + // [START] + // %% set x = 1 + // [A] + // %# plain comment + // [B] + // %#- trim comment + // [C] + // %% set y = 2 + // [D] + // [END] + // + // Python output: [START]\n[A]\n\n[B]\n[C]\n[D]\n[END] + // + // Semantics: + // %# (plain): comment content stripped, trailing \n KEPT → blank line where comment was + // %#- (trim): comment content AND trailing \n stripped → no blank line + // Neither form affects the newline that ended the preceding line. + + @Test + public void itStripsLineCommentPrefixLeavingBlankLine() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build() + ); + // %# keeps its trailing \n → "before\n" + "\n" (comment's own \n) + "after" + String template = "before\n%# this whole line is a comment\nafter"; + assertThat(j.render(template, new HashMap<>())).isEqualTo("before\n\nafter"); + } + + @Test + public void itStripsLineCommentWithLeadingWhitespace() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build() + ); + // Indentation before %# is stripped, trailing \n is kept → blank line + String template = "before\n %# indented comment\nafter"; + assertThat(j.render(template, new HashMap<>())).isEqualTo("before\n\nafter"); + } + + @Test + public void itStripsLineCommentWithTrimModifier() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build() + ); + // %# keeps trailing \n (blank line left in output) + assertThat(j.render("before\n%# comment\nafter", new HashMap<>())) + .isEqualTo("before\n\nafter"); + // %#- also keeps trailing \n — the '-' is LEFT-trim only (strips preceding blanks) + // With no preceding blank lines, result is identical to plain %# + assertThat(j.render("before\n%#- comment\nafter", new HashMap<>())) + .isEqualTo("before\nafter"); + // %#- with a preceding blank line: strips the blank, keeps own trailing \n + assertThat(j.render("before\n\n%#- comment\nafter", new HashMap<>())) + .isEqualTo("before\nafter"); + } + + @Test + public void itStripsLineCommentWithoutLeavingBlankLine() { + // %#- with real content before (no blank): strips the preceding \n, + // keeps comment's own \n. "\\begin{document}" + "\n" (comment's \n) + "\\section*{...}" + Jinjava j = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withTokenScannerSymbols( + StringTokenScannerSymbols + .builder() + .withVariableStartString("\\VAR{") + .withVariableEndString("}") + .withLineCommentPrefix("%#") + .build() + ) + .build() + ); + String template = + "\\begin{document}\n%#-\\VAR{reportHeader}\n\\section*{\\VAR{title}}"; + String result = j.render(template, ImmutableMap.of("title", "My Report")); + assertThat(result).isEqualTo("\\begin{document}\n\\section*{My Report}"); + } + + @Test + public void itHandlesBothLinePrefixesTogether() { + Jinjava j = jinjavaWith( + StringTokenScannerSymbols + .builder() + .withVariableStartString("<<") + .withVariableEndString(">>") + .withBlockStartString("<%") + .withBlockEndString("%>") + .withCommentStartString("<#") + .withCommentEndString("#>") + .withLineStatementPrefix("%%") + .withLineCommentPrefix("%#") + .build() + ); + String template = "%# this is stripped\n%% set x = 7\n<< x >>"; + // %# keeps its trailing \n → blank line, then %% set produces nothing, + // then << x >> renders as 7. Result: "\n7" + assertThat(j.render(template, new HashMap<>())).isEqualTo("\n7"); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + private Jinjava jinjavaWith(StringTokenScannerSymbols symbols) { + return new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withTokenScannerSymbols(symbols) + .build() + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/TagTokenTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/TagTokenTest.java index 192ea1173..bc2b56d6a 100644 --- a/src/test/java/com/hubspot/jinjava/tree/parse/TagTokenTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/parse/TagTokenTest.java @@ -3,40 +3,47 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown; -import org.junit.Test; - import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import org.junit.Test; public class TagTokenTest { + private static final TokenScannerSymbols SYMBOLS = new DefaultTokenScannerSymbols(); + @Test public void testParseTag() { - TagToken t = new TagToken("{% foo %}", 1); + TagToken t = new TagToken("{% foo %}", 1, 2, SYMBOLS); assertThat(t.getTagName()).isEqualTo("foo"); } @Test public void testParseTagWithHelpers() { - TagToken t = new TagToken("{% foo bar %}", 1); + TagToken t = new TagToken("{% foo bar %}", 1, 2, SYMBOLS); assertThat(t.getTagName()).isEqualTo("foo"); assertThat(t.getHelpers().trim()).isEqualTo("bar"); } @Test public void tagNameIsAllJavaIdentifiers() { - TagToken t = new TagToken("{%rich_text\"top_left\"%}", 1); + TagToken t = new TagToken("{%rich_text\"top_left\"%}", 1, 2, SYMBOLS); assertThat(t.getTagName()).isEqualTo("rich_text"); assertThat(t.getHelpers()).isEqualTo("\"top_left\""); } + @Test + public void itDefaultsNullSymbolsToDefaultTokenScannerSymbols() { + TagToken t = new TagToken("{% foo %}", 1, 2, null); + assertThat(t.getSymbols()).isInstanceOf(DefaultTokenScannerSymbols.class); + assertThat(t.getTagName()).isEqualTo("foo"); + } + @Test public void itThrowsParseErrorWhenMalformed() { try { - new TagToken("{% ", 1); + new TagToken("{% ", 1, 2, SYMBOLS); failBecauseExceptionWasNotThrown(TemplateSyntaxException.class); } catch (TemplateSyntaxException e) { assertThat(e).hasMessageContaining("Malformed"); } } - } diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/TokenScannerTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/TokenScannerTest.java index e79fbf4a6..2c5c1c8e4 100644 --- a/src/test/java/com/hubspot/jinjava/tree/parse/TokenScannerTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/parse/TokenScannerTest.java @@ -1,32 +1,36 @@ package com.hubspot.jinjava.tree.parse; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_EXPR_START; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_FIXED; -import static com.hubspot.jinjava.tree.parse.TokenScannerSymbols.TOKEN_NOTE; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Collectors; - import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.JinjavaConfig; - public class TokenScannerTest { + private JinjavaConfig config; private String script; private TokenScanner scanner; + private TokenScannerSymbols symbols; @Before public void setup() { - config = JinjavaConfig.newBuilder().build(); + config = BaseJinjavaTest.newConfigBuilder().build(); + symbols = config.getTokenScannerSymbols(); } @Test @@ -46,7 +50,7 @@ public void test3() { assertEquals("if x", scanner.next().content.trim()); Token tk = scanner.next(); assertEquals("{{{abc}}", tk.image); - assertEquals(TOKEN_EXPR_START, tk.getType()); + assertEquals(symbols.getExprStart(), tk.getType()); assertEquals("{%endif%}", scanner.next().image); } @@ -58,7 +62,7 @@ public void test4() { assertEquals("if x", scanner.next().content.trim()); Token tk = scanner.next(); assertEquals("{{!abc}}", tk.image); - assertEquals(TOKEN_EXPR_START, tk.getType()); + assertEquals(symbols.getExprStart(), tk.getType()); assertEquals("{%endif%}", scanner.next().image); } @@ -82,7 +86,7 @@ public void test7() { assertEquals("a", scanner.next().content.trim()); assertEquals("{{abc!}#}%}}", scanner.next().image); assertEquals("}", scanner.next().content.trim()); - assertEquals(TOKEN_FIXED, scanner.next().getType()); + assertEquals(symbols.getFixed(), scanner.next().getType()); } @Test @@ -93,7 +97,7 @@ public void test8() { assertEquals("{{abc.b}}", scanner.next().image); assertEquals("if x", scanner.next().content.trim()); assertEquals("a", scanner.next().content.trim()); - assertEquals(TOKEN_EXPR_START, scanner.next().getType()); + assertEquals(symbols.getExprStart(), scanner.next().getType()); assertEquals("{%endif{{", scanner.next().content.trim()); } @@ -105,7 +109,7 @@ public void test9() { assertEquals("{{abc.b}}", scanner.next().image); assertEquals("if x", scanner.next().content.trim()); assertEquals("a", scanner.next().content.trim()); - assertEquals(TOKEN_FIXED, scanner.next().getType()); + assertEquals(symbols.getFixed(), scanner.next().getType()); } @Test @@ -117,7 +121,7 @@ public void test10() { assertEquals("if x", scanner.next().content.trim()); assertEquals("a", scanner.next().content.trim()); assertEquals("{{abc}\\}{", scanner.next().image); - assertEquals(TOKEN_NOTE, scanner.next().getType()); + assertEquals(symbols.getNote(), scanner.next().getType()); } @Test @@ -140,19 +144,21 @@ public void test12() { assertEquals("if x", scanner.next().content.trim()); assertEquals("a", scanner.next().content.trim()); assertEquals("{{abc}\\}{{{", scanner.next().content.trim()); - assertEquals(TOKEN_NOTE, scanner.next().getType()); + assertEquals(symbols.getNote(), scanner.next().getType()); } @Test public void test13() { - script = "{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; + script = + "{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; scanner = new TokenScanner(script, config); assertEquals("{#abc{#.b#}", scanner.next().image); } @Test public void test14() { - script = "abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; + script = + "abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; scanner = new TokenScanner(script, config); assertEquals("abc", scanner.next().image); assertEquals("{#.b#}", scanner.next().image); @@ -161,7 +167,8 @@ public void test14() { @Test public void test15() { - script = "abc{#.b#}{#xy{!ad!}{#DD#}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; + script = + "abc{#.b#}{#xy{!ad!}{#DD#}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; scanner = new TokenScanner(script, config); assertEquals("abc", scanner.next().image); assertEquals("{#.b#}", scanner.next().image); @@ -170,24 +177,34 @@ public void test15() { @Test public void test16() { - script = "{#{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; + script = + "{#{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}a{{abc}\\}{{{{#endif{"; scanner = new TokenScanner(script, config); assertEquals("{#{#abc{#.b#}", scanner.next().image); } @Test public void test17() { - script = "{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}#}a#}{{abc}\\}#}{{{{#endif{"; + script = + "{#abc{#.b#}{#xy{!ad!}{%dbc%}{{dff}}d{#bc#}d#}#}{% if x %}#}a#}{{abc}\\}#}{{{{#endif{"; scanner = new TokenScanner(script, config); assertEquals("{#abc{#.b#}", scanner.next().image); } + @Test + public void itGetsPositionsOfTokens() { + List tokens = tokens("positions"); + assertThat(tokens.stream().map(Token::getStartPosition).collect(Collectors.toList())) + .isEqualTo(ImmutableList.of(1, 0, 4, 0, 6, 0, 6, 0, 4, 0, 4, 13, 25, 0, 1)); + } + @Test public void itProperlyTokenizesCommentBlocksContainingTags() { List tokens = tokens("comment-with-tags"); assertThat(tokens).hasSize(5); assertThat(tokens.get(4)).isInstanceOf(TagToken.class); - assertThat(StringUtils.substringBetween(tokens.get(4).toString(), "{%", "%}").trim()).isEqualTo("endif"); + assertThat(StringUtils.substringBetween(tokens.get(4).toString(), "{%", "%}").trim()) + .isEqualTo("endif"); } @Test @@ -195,7 +212,12 @@ public void itProperlyTokenizesCommentWithTrailingTokens() { List tokens = tokens("comment-plus"); assertThat(tokens).hasSize(2); assertThat(tokens.get(tokens.size() - 1)).isInstanceOf(TextToken.class); - assertThat(StringUtils.substringBetween(tokens.get(tokens.size() - 1).toString(), "{~", "~}").trim()).isEqualTo("and here's some extra."); + assertThat( + StringUtils + .substringBetween(tokens.get(tokens.size() - 1).toString(), "{~", "~}") + .trim() + ) + .isEqualTo("and here's some extra."); } @Test @@ -203,7 +225,8 @@ public void itProperlyTokenizesMultilineCommentTokens() { List tokens = tokens("multiline-comment"); assertThat(tokens).hasSize(3); assertThat(tokens.get(2)).isInstanceOf(TextToken.class); - assertThat(StringUtils.substringBetween(tokens.get(2).toString(), "{~", "~}").trim()).isEqualTo("goodbye."); + assertThat(StringUtils.substringBetween(tokens.get(2).toString(), "{~", "~}").trim()) + .isEqualTo("goodbye."); } @Test @@ -218,7 +241,7 @@ public void itProperlyTokenizesCommentWithStartCommentTokenWithin() { public void itProperlyTokenizesTagTokenWithTagTokenCharsWithinString() { List tokens = tokens("tag-with-tag-tokens-within-string"); assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).getType()).isEqualTo(TokenScannerSymbols.TOKEN_TAG); + assertThat(tokens.get(0).getType()).isEqualTo(symbols.getTag()); assertThat(tokens.get(0).content).contains("label='Blog Comments'"); } @@ -226,55 +249,119 @@ public void itProperlyTokenizesTagTokenWithTagTokenCharsWithinString() { public void testQuotedTag() { List tokens = tokens("html-with-tag-in-attr"); assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).getType()).isEqualTo(TokenScannerSymbols.TOKEN_FIXED); - assertThat(tokens.get(1).getType()).isEqualTo(TokenScannerSymbols.TOKEN_TAG); - assertThat(tokens.get(2).getType()).isEqualTo(TokenScannerSymbols.TOKEN_FIXED); + assertThat(tokens.get(0).getType()).isEqualTo(symbols.getFixed()); + assertThat(tokens.get(1).getType()).isEqualTo(symbols.getTag()); + assertThat(tokens.get(2).getType()).isEqualTo(symbols.getFixed()); } @Test public void testEscapedQuoteWithinAttrValue() { List tokens = tokens("tag-with-quot-in-attr"); assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).getType()).isEqualTo(TokenScannerSymbols.TOKEN_TAG); - assertThat(tokens.get(0).content.trim()).isEqualTo("widget_block rich_text \"module\" overrideable=True, label='

    We\\'ve included a great symbol

    '"); + assertThat(tokens.get(0).getType()).isEqualTo(symbols.getTag()); + assertThat(tokens.get(0).content.trim()) + .isEqualTo( + "widget_block rich_text \"module\" overrideable=True, label='

    We\\'ve included a great symbol

    '" + ); + } + + @Test + public void testCommentWithWhitespaceChar() { + List tokens = tokens("comment-without-whitespace"); + assertThat(tokens.get(0).content.trim()).isEqualTo("$"); + + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + BuiltInFeatures.WHITESPACE_REQUIRED_WITHIN_TOKENS, + FeatureStrategies.ACTIVE + ) + .build() + ) + .build(); + TokenScanner scanner = fixture("comment-without-whitespace", config); + tokens = Lists.newArrayList(scanner); + assertThat(tokens.get(0).content.trim()).isEqualTo("${#array[@]}"); } @Test public void testEscapedBackslashWithinAttrValue() { List tokens = tokens("escape-char-tokens"); - List tagHelpers = tokens.stream() - .filter(t -> t.getType() == TokenScannerSymbols.TOKEN_TAG) - .map(t -> ((TagToken) t).getHelpers().trim().substring(1, 26)) - .collect(Collectors.toList()); + List tagHelpers = tokens + .stream() + .filter(t -> t.getType() == symbols.getTag()) + .map(t -> ((TagToken) t).getHelpers().trim().substring(1, 26)) + .collect(Collectors.toList()); - assertThat(tagHelpers).containsExactly( + assertThat(tagHelpers) + .containsExactly( "module_143819779285827357", "module_143819780991527688", - "module_143819781983527999"); + "module_143819781983527999" + ); } - private List tokens(String fixture) { - TokenScanner t = fixture(fixture); + @Test + public void testLstripBlocks() { + config = + BaseJinjavaTest + .newConfigBuilder() + .withLstripBlocks(true) + .withTrimBlocks(true) + .build(); + + List tokens = tokens("tag-with-trim-chars"); + assertThat(tokens).isNotEmpty(); + } - List tokens = Lists.newArrayList(); - while (t.hasNext()) { - tokens.add(t.next()); - } + @Test + public void itTreatsEscapedQuotesSameWhenNotInQuotes() { + config = + BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withHandleBackslashInQuotesOnly(false).build() + ) + .build(); + List tokens = tokens("tag-with-all-escaped-quotes"); + assertThat(tokens).hasSize(8); + assertThat(tokens.stream().map(Token::getType).collect(Collectors.toList())) + .containsExactly( + symbols.getNote(), + symbols.getFixed(), + symbols.getTag(), + symbols.getFixed(), + symbols.getTag(), + symbols.getFixed(), + symbols.getTag(), + symbols.getFixed() + ); + } - return tokens; + private List tokens(String fixture) { + TokenScanner t = fixture(fixture); + return Lists.newArrayList(t); } private TokenScanner fixture(String fixture) { + return fixture(fixture, config); + } + + private TokenScanner fixture(String fixture, JinjavaConfig config) { try { - TokenScanner t = new TokenScanner( - Resources.toString(Resources.getResource(String.format("parse/tokenizer/%s.jinja", fixture)), - StandardCharsets.UTF_8), - config); - return t; + return new TokenScanner( + Resources.toString( + Resources.getResource(String.format("parse/tokenizer/%s.jinja", fixture)), + StandardCharsets.UTF_8 + ), + config + ); } catch (Exception e) { throw new RuntimeException(e); } } - } diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/TokenWhitespaceTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/TokenWhitespaceTest.java index 7ea9059c7..09c0f643a 100644 --- a/src/test/java/com/hubspot/jinjava/tree/parse/TokenWhitespaceTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/parse/TokenWhitespaceTest.java @@ -2,36 +2,50 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; - import org.junit.Test; -import com.google.common.base.Throwables; -import com.google.common.collect.Lists; -import com.google.common.io.Resources; -import com.hubspot.jinjava.JinjavaConfig; - public class TokenWhitespaceTest { @Test public void trimBlocksTrimsAfterTag() { - List tokens = scanTokens("parse/tokenizer/whitespace-tags.jinja", trimBlocksConfig()); + List tokens = scanTokens( + "parse/tokenizer/whitespace-tags.jinja", + trimBlocksConfig() + ); + assertThat(tokens.get(2).getImage()).isEqualTo(" yay\n "); + } + + @Test + public void trimBlocksTrimsAfterCommentTag() { + List tokens = scanTokens( + "parse/tokenizer/whitespace-comment-tags.jinja", + trimBlocksConfig() + ); assertThat(tokens.get(2).getImage()).isEqualTo(" yay\n "); + assertThat(tokens.get(4).getImage()).isEqualTo(" whoop\n\n"); } private List scanTokens(String srcPath, JinjavaConfig config) { try { - return Lists.newArrayList(new TokenScanner( - Resources.toString(Resources.getResource(srcPath), StandardCharsets.UTF_8), config)); + return Lists.newArrayList( + new TokenScanner( + Resources.toString(Resources.getResource(srcPath), StandardCharsets.UTF_8), + config + ) + ); } catch (IOException e) { - throw Throwables.propagate(e); + throw new RuntimeException(e); } } private JinjavaConfig trimBlocksConfig() { - return JinjavaConfig.newBuilder().withTrimBlocks(true).build(); + return BaseJinjavaTest.newConfigBuilder().withTrimBlocks(true).build(); } - } diff --git a/src/test/java/com/hubspot/jinjava/util/CharArrayUtilsTest.java b/src/test/java/com/hubspot/jinjava/util/CharArrayUtilsTest.java new file mode 100644 index 000000000..a9d9a6a44 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/CharArrayUtilsTest.java @@ -0,0 +1,19 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +public class CharArrayUtilsTest { + + @Test + public void testCharArrayRegionMatches() { + char[] s = "hello {% raw %} world {% endraw %}".toCharArray(); + + assertThat(CharArrayUtils.charArrayRegionMatches(s, 0, "raw")).isFalse(); + assertThat(CharArrayUtils.charArrayRegionMatches(s, 9, "raw")).isTrue(); + + assertThat(CharArrayUtils.charArrayRegionMatches(s, 25, "endraw")).isTrue(); + assertThat(CharArrayUtils.charArrayRegionMatches(s, 29, "endraw")).isFalse(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/DeferredValueUtilsTest.java b/src/test/java/com/hubspot/jinjava/util/DeferredValueUtilsTest.java new file mode 100644 index 000000000..735d84361 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/DeferredValueUtilsTest.java @@ -0,0 +1,360 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.tree.ExpressionNode; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.tree.parse.Token; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class DeferredValueUtilsTest { + + @Test + public void itFindsGlobalProperties() { + Context context = new Context(); + context.put("java_bean", getPopulatedJavaBean()); + + context = + getContext( + Lists.newArrayList(getNodeForClass(TagNode.class, "{% if java_bean %}")), + Optional.of(context) + ); + + Context finalContext = context; + Set deferredProperties = context + .getDeferredNodes() + .stream() + .flatMap(node -> + DeferredValueUtils.findAndMarkDeferredProperties(finalContext, node).stream() + ) + .collect(Collectors.toSet()); + + assertThat(deferredProperties).contains("java_bean"); + } + + @Test + public void itDefersWholePropertyOnArrayAccess() { + Context context = getContext( + Lists.newArrayList(getNodeForClass(TagNode.class, "{{ array[0] }}")) + ); + context.put("array", Lists.newArrayList("a", "b", "c")); + + Set deferredProperties = context + .getDeferredNodes() + .stream() + .flatMap(node -> + DeferredValueUtils.findAndMarkDeferredProperties(context, node).stream() + ) + .collect(Collectors.toSet()); + assertThat(deferredProperties).contains("array"); + } + + @Test + public void itDefersWholePropertyOnDictAccess() { + Context context = getContext( + Lists.newArrayList(getNodeForClass(TagNode.class, "{{ dict['a'] }}")) + ); + context.put("dict", Collections.singletonMap("a", "x")); + + Set deferredProperties = context + .getDeferredNodes() + .stream() + .flatMap(node -> + DeferredValueUtils.findAndMarkDeferredProperties(context, node).stream() + ) + .collect(Collectors.toSet()); + assertThat(deferredProperties).contains("dict"); + } + + @Test + public void itDefersTheCompleteObjectWhenAtLeastOnePropertyIsUsed() { + Context context = new Context(); + context.put("java_bean", getPopulatedJavaBean()); + + context = + getContext( + Lists.newArrayList( + getNodeForClass( + TagNode.class, + "{% if java_bean.property_one %}", + Optional.empty(), + Optional.empty() + ) + ), + Optional.of(context) + ); + + Context finalContext = context; + context + .getDeferredNodes() + .forEach(node -> + DeferredValueUtils.findAndMarkDeferredProperties(finalContext, node) + ); + assertThat(context.containsKey("java_bean")).isTrue(); + assertThat(context.get("java_bean")).isInstanceOf(DeferredValue.class); + DeferredValue deferredValue = (DeferredValue) context.get("java_bean"); + JavaBean originalValue = (JavaBean) deferredValue.getOriginalValue(); + assertThat(originalValue).hasFieldOrPropertyWithValue("propertyOne", "propertyOne"); + assertThat(originalValue).hasFieldOrPropertyWithValue("propertyTwo", "propertyTwo"); + } + + @Test + public void itHandlesCaseWhereValueIsNull() { + Context context = getContext( + Lists.newArrayList( + getNodeForClass( + TagNode.class, + "{% if property.id %}", + Optional.empty(), + Optional.empty() + ) + ) + ); + context.put("property", null); + context + .getDeferredNodes() + .forEach(node -> DeferredValueUtils.findAndMarkDeferredProperties(context, node)); + assertThat(context.get("property")).isNull(); + } + + @Test + public void itPreservesNonDeferredProperties() { + Context context = getContext( + Lists.newArrayList( + getNodeForClass( + TagNode.class, + "{% if deferred %}", + Optional.empty(), + Optional.empty() + ) + ) + ); + context.put("deferred", "deferred"); + context.put("not_deferred", "test_value"); + + context + .getDeferredNodes() + .forEach(node -> DeferredValueUtils.findAndMarkDeferredProperties(context, node)); + assertThat(context.get("not_deferred")).isEqualTo("test_value"); + } + + @Test + public void itRestoresContextSuccessfully() { + Context context = new Context(); + ImmutableMap simpleMap = ImmutableMap.of("a", "x", "b", "y"); + ImmutableMap nestedMap = ImmutableMap.of("nested", simpleMap); + Integer[] simpleArray = { 1, 2, 3, 4, 5, 6 }; + JavaBean javaBean = getPopulatedJavaBean(); + context.put("simple_var", DeferredValue.instance("SimpleVar")); + context.put("java_bean", DeferredValue.instance(javaBean)); + context.put("simple_bool", DeferredValue.instance(true)); + context.put("simple_array", DeferredValue.instance(simpleArray)); + context.put("simple_map", DeferredValue.instance(simpleMap)); + context.put("nested_map", DeferredValue.instance(nestedMap)); + + context.put("simple_var_undeferred", "SimpleVarUnDeferred"); + context.put("java_bean_undeferred", javaBean); + context.put("nested_map_undeferred", nestedMap); + + HashMap result = + DeferredValueUtils.getDeferredContextWithOriginalValues(context); + assertThat(result).contains(entry("simple_var", "SimpleVar")); + assertThat(result).contains(entry("java_bean", javaBean)); + assertThat(result).contains(entry("simple_bool", true)); + assertThat(result).contains(entry("simple_array", simpleArray)); + assertThat(result).contains(entry("simple_map", simpleMap)); + assertThat(result).contains(entry("nested_map", nestedMap)); + + assertThat(result) + .doesNotContain(entry("simple_var_undeferred", "SimpleVarUnDeferred")); + assertThat(result).doesNotContain(entry("java_bean_undeferred", javaBean)); + assertThat(result).doesNotContain(entry("nested_map_undeferred", nestedMap)); + } + + @Test + public void itIgnoresUnrestorableValuesFromDeferredContext() { + Context context = new Context(); + context.put("simple_var", DeferredValue.instance()); + context.put("java_bean", DeferredValue.instance()); + + HashMap result = + DeferredValueUtils.getDeferredContextWithOriginalValues(context); + assertThat(result).isEmpty(); + } + + @Test + public void itDefersSetWordsInDeferredTokens() { + Context context = new Context(); + context.put("var_a", "a"); + DeferredToken deferredToken = DeferredToken + .builderFromToken( + new TagToken( + "{% set var_a, var_b = deferred, deferred %}", + 1, + 1, + new DefaultTokenScannerSymbols() + ) + ) + .addSetDeferredWords(ImmutableSet.of("var_a", "var_b")) + .build(); + context.handleDeferredToken(deferredToken); + assertThat(context.get("var_a")).isInstanceOf(DeferredValue.class); + assertThat(context.get("var_b")).isInstanceOf(DeferredValue.class); + } + + @Test + public void itDefersUsedWordsInDeferredTokens() { + Context context = new Context(); + context.put("var_a", "a"); + DeferredToken deferredToken = DeferredToken + .builderFromToken( + new ExpressionToken( + "{{ var_a.append(deferred|int)}}", + 1, + 1, + new DefaultTokenScannerSymbols() + ) + ) + .addUsedDeferredWords(ImmutableSet.of("var_a", "int")) + .build(); + + context.handleDeferredToken(deferredToken); + assertThat(context.get("var_a")).isInstanceOf(DeferredValue.class); + assertThat(context.containsKey("int")).isFalse(); + } + + @Test + public void itFindsFirstValidDeferredWords() { + DeferredToken deferredToken = DeferredToken + .builderFromToken( + new ExpressionToken("{{ blah }}", 1, 1, new DefaultTokenScannerSymbols()) + ) + .addUsedDeferredWords(ImmutableSet.of("deferred", ".attribute1")) + .addSetDeferredWords(ImmutableSet.of("deferred", ".attribute2")) + .build(); + + assertThat(deferredToken.getUsedDeferredBases()) + .isEqualTo(ImmutableSet.of("deferred", "attribute1")); + assertThat(deferredToken.getSetDeferredBases()) + .isEqualTo(ImmutableSet.of("deferred", "attribute2")); + } + + @Test + public void itFindsFirstValidDeferredWordsWithNestedAttributes() { + DeferredToken deferredToken = DeferredToken + .builderFromToken( + new ExpressionToken("{{ blah }}", 1, 1, new DefaultTokenScannerSymbols()) + ) + .addUsedDeferredWords(ImmutableSet.of("deferred", ".attribute1.ignore")) + .addSetDeferredWords(ImmutableSet.of("deferred", ".attribute2.ignoreme")) + .build(); + + assertThat(deferredToken.getUsedDeferredBases()) + .isEqualTo(ImmutableSet.of("deferred", "attribute1")); + assertThat(deferredToken.getSetDeferredBases()) + .isEqualTo(ImmutableSet.of("deferred", "attribute2")); + } + + private Context getContext(List nodes) { + return getContext(nodes, Optional.empty()); + } + + private Context getContext( + List nodes, + Optional initialContext + ) { + Context context = new Context(); + + if (initialContext.isPresent()) { + context = initialContext.get(); + } + for (Node node : nodes) { + context.handleDeferredNode(node); + } + return context; + } + + private T getNodeForClass(Class clazz, String image) { + return getNodeForClass(clazz, image, Optional.empty(), Optional.empty()); + } + + private T getNodeForClass( + Class clazz, + String image, + Optional> childNodes, + Optional endName + ) { + T node = mock(clazz); + Token token = mock(Token.class); + if (childNodes.isPresent()) { + LinkedList children = new LinkedList<>(); + children.addAll(childNodes.get()); + when(node.getChildren()).thenReturn(children); + } + when(token.getImage()).thenReturn(image); + when(node.getMaster()).thenReturn(token); + if (node instanceof ExpressionNode) { + when(node.toString()).thenReturn(image); + } + if (node instanceof TagNode && endName.isPresent()) { + TagNode tagNode = (TagNode) node; + when(tagNode.getEndName()).thenReturn(endName.get()); + when(tagNode.reconstructEnd()).thenReturn("{% " + endName.get() + " %}"); + } + return node; + } + + private JavaBean getPopulatedJavaBean() { + JavaBean javaBean = new JavaBean(); + javaBean.setPropertyOne("propertyOne"); + javaBean.setPropertyTwo("propertyTwo"); + return javaBean; + } + + private class JavaBean { + + String propertyOne; + String propertyTwo; + + public String getPropertyOne() { + return propertyOne; + } + + public JavaBean setPropertyOne(String propertyOne) { + this.propertyOne = propertyOne; + return this; + } + + public String getPropertyTwo() { + return propertyTwo; + } + + public JavaBean setPropertyTwo(String propertyTwo) { + this.propertyTwo = propertyTwo; + return this; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/EagerExpressionResolverTest.java b/src/test/java/com/hubspot/jinjava/util/EagerExpressionResolverTest.java new file mode 100644 index 000000000..1065349cb --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/EagerExpressionResolverTest.java @@ -0,0 +1,932 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.el.ext.AbstractCallableMethod; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.objects.date.PyishDate; +import com.hubspot.jinjava.objects.serialization.PyishObjectMapper; +import com.hubspot.jinjava.random.RandomNumberGeneratorStrategy; +import com.hubspot.jinjava.testobjects.EagerExpressionResolverTestObjects; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.tree.parse.TokenScannerSymbols; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerExpressionResolverTest { + + private static final TokenScannerSymbols SYMBOLS = new DefaultTokenScannerSymbols(); + + private JinjavaInterpreter interpreter; + private TagToken tagToken; + private Context context; + + @Before + public void setUp() throws Exception { + JinjavaInterpreter.pushCurrent(getInterpreter(false)); + } + + private JinjavaInterpreter getInterpreter(boolean evaluateMapKeys) throws Exception { + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .withRandomNumberGeneratorStrategy(RandomNumberGeneratorStrategy.DEFERRED) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withEvaluateMapKeys(evaluateMapKeys).build() + ) + .withNestedInterpretationEnabled(true) + .build() + ); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "void_function", + this.getClass().getDeclaredMethod("voidFunction", int.class) + ) + ); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "is_null", + this.getClass().getDeclaredMethod("isNull", Object.class, Object.class) + ) + ); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "sleeper", + this.getClass().getDeclaredMethod("sleeper") + ) + ); + jinjava + .getGlobalContext() + .registerFunction( + new ELFunctionDefinition( + "", + "optionally", + this.getClass().getDeclaredMethod("optionally", boolean.class) + ) + ); + interpreter = new JinjavaInterpreter(jinjava.newInterpreter()); + context = interpreter.getContext(); + context.put("deferred", DeferredValue.instance()); + tagToken = new TagToken("{% foo %}", 1, 2, SYMBOLS); + return interpreter; + } + + @After + public void cleanup() { + JinjavaInterpreter.popCurrent(); + } + + private EagerExpressionResult eagerResolveExpression(String string) { + return EagerExpressionResolver.resolveExpression(string, interpreter); + } + + @Test + public void itResolvesDeferredBoolean() { + context.put("foo", "foo_val"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "(111 == 112) or (foo == deferred)" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("false || ('foo_val' == deferred)"); + assertThat(eagerExpressionResult.getDeferredWords()).containsExactly("deferred"); + + context.put("deferred", "foo_val"); + assertThat(eagerResolveExpression(partiallyResolved).toString()).isEqualTo("true"); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)).isEqualTo(true); + } + + @Test + public void itSerializesNestedOptional() { + assertThat(eagerResolveExpression("[optionally(true)]").toString()) + .isEqualTo("['1']"); + assertThat(eagerResolveExpression("[optionally(false)]").toString()) + .isEqualTo("[null]"); + } + + @Test + public void itResolvesDeferredList() { + context.put("foo", "foo_val"); + context.put("bar", "bar_val"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[foo == bar, deferred, bar]" + ); + assertThat(eagerExpressionResult.toString()) + .isEqualTo("[false, deferred, 'bar_val']"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred"); + context.put("bar", "foo_val"); + eagerExpressionResult = eagerResolveExpression("[foo == bar, deferred, bar]"); + assertThat(eagerExpressionResult.toString()).isEqualTo("[true, deferred, 'foo_val']"); + } + + @Test + public void itResolvesSimpleBoolean() { + context.put("foo", true); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[false || (foo), 'bar']" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[true, 'bar']"); + assertThat(eagerExpressionResult.getDeferredWords()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + public void itResolvesRange() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("range(0,2)"); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[0, 1]"); + assertThat(eagerExpressionResult.getDeferredWords()).isEmpty(); + // I don't know why this is a list of longs? + assertThat((List) interpreter.resolveELExpression(partiallyResolved, 1)) + .contains(0L, 1L); + } + + @Test + @SuppressWarnings("unchecked") + public void itResolvesDeferredRange() throws Exception { + List expectedList = ImmutableList.of(1, 2, 3); + context.put("foo", 1); + context.put("bar", 3); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "range(deferred, foo + bar)" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("range(deferred, 4)"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred", "range"); + + context.put("deferred", 1); + assertThat(eagerResolveExpression(partiallyResolved).toString()) + .isEqualTo(PyishObjectMapper.getAsPyishString(expectedList)); + // But this is a list of integers + assertThat((List) interpreter.resolveELExpression(partiallyResolved, 1)) + .isEqualTo(expectedList); + } + + @Test + @SuppressWarnings("unchecked") + public void itResolvesDictionary() { + Map dict = ImmutableMap.of("foo", "one", "bar", 2L); + context.put("the_dictionary", dict); + + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[the_dictionary, 1]" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(eagerExpressionResult.getDeferredWords()).isEmpty(); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)) + .isEqualTo(ImmutableList.of(dict, 1L)); + } + + @Test + public void itResolvesNested() { + context.put("foo", 1); + context.put("bar", 3); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[foo, range(deferred, bar), range(foo, bar)][0:2]" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[1, range(deferred, 3), [1, 2]][0:2]"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred", "range"); + + context.put("deferred", 2); + assertThat(eagerResolveExpression(partiallyResolved).toString()) + .isEqualTo("[1, [2]]"); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)) + .isEqualTo(ImmutableList.of(1L, ImmutableList.of(2))); + } + + @Test + public void itSplitsOnNonWords() { + context.put("foo", 1); + context.put("bar", 4); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "range(0,foo) + -deferred/bar" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[0] + -deferred / 4"); + assertThat(eagerExpressionResult.getDeferredWords()).containsExactly("deferred"); + + context.put("deferred", 2); + assertThat(eagerResolveExpression(partiallyResolved).toString()) + .isEqualTo("[0, -0.5]"); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)) + .isEqualTo(ImmutableList.of(0L, -0.5)); + } + + @Test + public void itSplitsAndIndexesOnNonWords() { + context.put("foo", 3); + context.put("bar", 4); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "range(-2,foo)[-1] + -deferred/bar" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("2 + -deferred / 4"); + assertThat(eagerExpressionResult.getDeferredWords()).containsExactly("deferred"); + + context.put("deferred", 2); + assertThat(eagerResolveExpression(partiallyResolved).toString()).isEqualTo("1.5"); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)).isEqualTo(1.5); + } + + @Test + public void itSupportsOrderOfOperations() { + eagerResolveExpression("['a','b'][1]"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[0,1]|reverse + deferred" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[1, 0] + deferred"); + assertThat(eagerExpressionResult.getDeferredWords()).containsExactly("deferred"); + + context.put("deferred", 2L); + assertThat(eagerResolveExpression(partiallyResolved).toString()) + .isEqualTo("[1, 0, 2]"); + assertThat(interpreter.resolveELExpression(partiallyResolved, 1)) + .isEqualTo(ImmutableList.of(1L, 0L, 2L)); + } + + @Test + public void itCatchesDeferredVariables() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "range(0, deferred)" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("range(0, deferred)"); + // Since the range function is deferred, it is added to deferredWords. + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("range", "deferred"); + } + + @Test + public void itDoesntDeferReservedWords() { + context.put("foo", 0); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "[(foo > 1) || deferred, deferred].append(1)" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("[false || deferred, deferred].append(1)"); + assertThat(eagerExpressionResult.getDeferredWords()).doesNotContain("false", "or"); + assertThat(eagerExpressionResult.getDeferredWords()).contains("deferred", ".append"); + } + + @Test + public void itEvaluatesDict() { + context.put("foo", new PyMap(ImmutableMap.of("bar", 99))); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "foo.bar == deferred.bar" + ); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("99 == deferred.bar"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred.bar"); + } + + @Test + public void itSerializesDateProperly() throws IOException { + PyishDate date = new PyishDate( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(1234567890L), ZoneId.systemDefault()) + ); + context.put("date", date); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("date"); + + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo(date.appendPyishString(new StringBuilder()).toString()); + interpreter.render( + "{% set foo = " + + PyishObjectMapper.getAsPyishString(ImmutableMap.of("a", date)) + + " %}" + ); + assertThat( + ((PyishDate) ((Map) interpreter.getContext().get("foo")).get( + "a" + )).toDateTime() + ) + .isEqualTo(date.toDateTime()); + } + + @Test + public void itHandlesSingleQuotes() { + context.put("foo", "'"); + context.put("bar", '\''); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "foo ~ ' & ' ~ bar ~ ' & ' ~ '\\'\\\"'" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("' & ' & '\""); + } + + @Test + public void itHandlesEscapedSlashBeforeQuoteProperly() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "deferred|replace('\\\\', '.')" + ); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred", "replace.filter"); + } + + @Test + public void itHandlesNewlines() { + context.put("foo", "\n"); + context.put("bar", "\\" + "n"); // Jinja doesn't see this as a newline. + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "foo ~ ' & ' ~ bar ~ ' & ' ~ '\\\\' ~ 'n' ~ ' & \\\\n'" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("\n & \\n & \\n & \\n"); + } + + @Test + public void itOutputsUnknownVariablesAsEmpty() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "contact.some_odd_property" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo(""); + } + + @Test + public void itHandlesCancellingSlashes() { + context.put("foo", "bar"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "foo ~ 'foo\\\\' ~ foo ~ 'foo'" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("barfoo\\barfoo"); + } + + @Test + public void itOutputsEmptyForVoidFunctions() throws Exception { + assertThat( + WhitespaceUtils.unquoteAndUnescape(interpreter.render("{{ void_function(2) }}")) + ) + .isEmpty(); + assertThat( + WhitespaceUtils.unquoteAndUnescape( + eagerResolveExpression("void_function(2)").toString() + ) + ) + .isEmpty(); + } + + @Test + public void itOutputsNullAsEmptyString() { + assertThat(eagerResolveExpression("void_function(2)").toString()).isEqualTo("''"); + assertThat(eagerResolveExpression("nothing").toString()).isEqualTo("''"); + } + + @Test + public void itInterpretsNullAsNull() { + assertThat(eagerResolveExpression("is_null(nothing, null)").toString()) + .isEqualTo("true"); + assertThat(eagerResolveExpression("is_null(void_function(2), nothing)").toString()) + .isEqualTo("true"); + assertThat(eagerResolveExpression("is_null('', nothing)").toString()) + .isEqualTo("false"); + } + + @Test + public void itDoesntDeferNull() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "range(deferred, nothing)" + ); + assertThat(eagerExpressionResult.toString()).isEqualTo("range(deferred, null)"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("range", "deferred"); + } + + @Test + public void itDoesntSplitOnBar() { + context.put("date", new PyishDate(0L)); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "date|datetimeformat('%Y')" + ); + assertThat(eagerExpressionResult.toString()).isEqualTo("'1970'"); + } + + @Test + public void itDoesntResolveNonPyishSerializable() { + PyMap dict = new PyMap(new HashMap<>()); + context.put("dict", dict); + context.put("foo", new EagerExpressionResolverTestObjects.Foo("bar")); + context.put("mark", "!"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "dict.update({'foo': foo})" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo(""); + assertThat(dict.get("foo")) + .isInstanceOf(EagerExpressionResolverTestObjects.Foo.class); + assertThat(((EagerExpressionResolverTestObjects.Foo) dict.get("foo")).bar()) + .isEqualTo("bar"); + } + + @Test + public void itLeavesPaddedZeros() { + context.put( + "zero_date", + ZonedDateTime.ofInstant(Instant.ofEpochMilli(0L), ZoneId.systemDefault()) + ); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "zero_date.strftime('%d')" + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("01"); + } + + @Test + public void itPreservesLengthyDoubleStrings() { + // does not convert to scientific notation + context.put("small", "0.0000000001"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("small"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("0.0000000001"); + } + + @Test + public void itConvertsDoubles() { + // does convert to scientific notation + context.put("small", 0.0000000001); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("small"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("1.0E-10"); + } + + @Test + public void itDoesntQuoteFloats() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("0.4 + 0.1"); + assertThat(eagerExpressionResult.toString()).isEqualTo("0.5"); + } + + @Test + public void itHandlesWhitespaceAroundPipe() { + String lowerFilterString = + "'AB' | truncate(1) ~ 'BC' |truncate(1) ~ 'CD'| truncate(1)"; + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + lowerFilterString + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo(interpreter.resolveELExpression(lowerFilterString, 0)); + } + + @Test + public void itHandlesMultipleWhitespaceAroundPipe() { + String lowerFilterString = "'AB' | truncate(1)"; + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + lowerFilterString + ); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo(interpreter.resolveELExpression(lowerFilterString, 0)); + } + + @Test + public void itEscapesFormFeed() { + context.put("foo", "Form feed\f"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("foo"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("Form feed\f"); + } + + @Test + public void itHandlesUnconventionalSpacing() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "( range (0 , 3 ) [ 1] + deferred) ~ 'YES'| lower" + ); + String result = WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString()); + assertThat(result).isEqualTo("(1 + deferred) ~ 'yes'"); + context.put("deferred", 2); + assertThat(interpreter.resolveELExpression(result, 0)).isEqualTo("3yes"); + } + + @Test + public void itHandlesDotSpacing() { + context.put("bar", "fake"); + context.put("foo", ImmutableMap.of("bar", "foobar")); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("foo . bar"); + String result = WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString()); + assertThat(result).isEqualTo("foobar"); + } + + @Test + public void itPreservesLegacyDictionaryCreation() { + context.put("foo", "not_foo"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("{foo: 'bar'}"); + String result = WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString()); + assertThat(result).isEqualTo("{'foo': 'bar'}"); + } + + @Test + public void itHandlesPythonicDictionaryCreation() throws Exception { + JinjavaInterpreter.pushCurrent(getInterpreter(true)); + try { + context.put("foo", "not_foo"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "{foo: 'bar'}" + ); + String result = WhitespaceUtils.unquoteAndUnescape( + eagerExpressionResult.toString() + ); + assertThat(result).isEqualTo("{'not_foo': 'bar'}"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @Test + public void itKeepsPlusSignPrefix() { + context.put("foo", "+12223334444"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("foo"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("+12223334444"); + } + + @Test + public void itHandlesPhoneNumbers() { + context.put("foo", "+1(123)456-7890"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("foo"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("+1(123)456-7890"); + } + + @Test + public void itHandlesNegativeZero() { + context.put("foo", "-0"); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression("foo"); + assertThat(WhitespaceUtils.unquoteAndUnescape(eagerExpressionResult.toString())) + .isEqualTo("-0"); + } + + @Test + public void itHandlesPyishSerializable() { + context.put("foo", new EagerExpressionResolverTestObjects.SomethingPyish("yes")); + assertThat( + interpreter.render( + String.format("{{ %s.name }}", eagerResolveExpression("foo").toString()) + ) + ) + .isEqualTo("yes"); + } + + @Test + public void itHandlesPyishSerializableWithProcessingException() { + context.put( + "foo", + new EagerExpressionResolverTestObjects.SomethingExceptionallyPyish("yes") + ); + context.addMetaContextVariables(Collections.singleton("foo")); + assertThat(interpreter.render("{{ deferred && (1 == 2 || foo) }}")) + .isEqualTo("{{ deferred && (false || foo) }}"); + } + + @Test + public void itFinishesResolvingList() { + assertThat(eagerResolveExpression("[0 + 1, deferred, 2 + 1]").toString()) + .isEqualTo("[1, deferred, 3]"); + } + + @Test + public void itHandlesExtraSapces() { + context.put("foo", " foo"); + assertThat(eagerResolveExpression("foo").toString()).isEqualTo("' foo'"); + } + + @Test + public void itHandlesDeferredExpTests() { + context.put("foo", 4); + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "foo is not equalto deferred" + ); + interpreter.getContext().setThrowInterpreterErrors(true); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved) + .isEqualTo("exptest:equalto.evaluateNegated(4, ____int3rpr3t3r____, deferred)"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred", "equalto.evaluateNegated"); + context.put("deferred", 4); + assertThat(eagerResolveExpression(partiallyResolved).toString()).isEqualTo("false"); + context.put("deferred", 1); + assertThat(eagerResolveExpression(partiallyResolved).toString()).isEqualTo("true"); + } + + @Test + public void itHandlesDeferredChoice() { + context.put("foo", "foo"); + context.put("bar", "bar"); + assertThat(eagerResolveExpression("deferred ? foo : bar").toString()) + .isEqualTo("deferred ? 'foo' : 'bar'"); + assertThat(eagerResolveExpression("true ? deferred : bar").toString()) + .isEqualTo("deferred"); + assertThat(eagerResolveExpression("false ? foo : deferred").toString()) + .isEqualTo("deferred"); + assertThat(eagerResolveExpression("null ? foo : deferred").toString()) + .isEqualTo("deferred"); + } + + @Test + public void itGetsDeferredWordsFromNestedExpression() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "deferred.append('{{ foo }}')" + ); + interpreter.getContext().setThrowInterpreterErrors(true); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("deferred.append('{{ foo }}')"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred.append", "foo"); + } + + @Test + public void itGetsDeferredWordsFromAdjacentNestedExpression() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "deferred.append('{{ foo }} and {{ bar }}')" + ); + interpreter.getContext().setThrowInterpreterErrors(true); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("deferred.append('{{ foo }} and {{ bar }}')"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred.append", "foo", "bar"); + } + + @Test + public void itGetsDeferredWordsFromMultipleNestedExpression() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "deferred.append('{{ foo ~ \\'and {{ bar }}\\' }}')" + ); + interpreter.getContext().setThrowInterpreterErrors(true); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved) + .isEqualTo("deferred.append('{{ foo ~ \\'and {{ bar }}\\' }}')"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred.append", "foo", "bar"); + } + + @Test + public void itGetsMultipleDeferredWordsNestedExpression() { + EagerExpressionResult eagerExpressionResult = eagerResolveExpression( + "deferred.append('{{ foo.append(bar) }}')" + ); + interpreter.getContext().setThrowInterpreterErrors(true); + String partiallyResolved = eagerExpressionResult.toString(); + assertThat(partiallyResolved).isEqualTo("deferred.append('{{ foo.append(bar) }}')"); + assertThat(eagerExpressionResult.getDeferredWords()) + .containsExactlyInAnyOrder("deferred.append", "foo.append", "bar"); + } + + @Test(expected = DeferredValueException.class) + public void itFailsWhenThereIsANestedTag() { + eagerResolveExpression("deferred.append('{% do foo.append(bar) %}')"); + } + + @Test(expected = DeferredValueException.class) + public void itFailsWhenThereIsANestedTagFromSeparateQuoteBlocks() { + eagerResolveExpression( + "\"{% import '../../settings/localization/\" + deferred + \"-website.html' as config %}\"" + ); + } + + @Test + public void itHandlesDeferredNamedParameter() { + context.put("foo", "foo"); + EagerExpressionResult result = eagerResolveExpression("[x=foo, y=deferred]"); + assertThat(result.toString()).isEqualTo("[x='foo', y=deferred]"); + assertThat(result.getDeferredWords()).containsExactly("deferred"); + } + + @Test + public void itHandlesDeferredValueInList() { + context.put("foo", "foo"); + assertThat(eagerResolveExpression("[foo, deferred, foo ~ '!']").toString()) + .isEqualTo("['foo', deferred, 'foo!']"); + } + + @Test + public void itHandlesDeferredValueInTuple() { + context.put("foo", "foo"); + assertThat(eagerResolveExpression("(foo, deferred, foo ~ '!')").toString()) + .isEqualTo("('foo', deferred, 'foo!')"); + } + + @Test + public void itHandlesDeferredMethod() { + context.put("foo", "foo"); + context.put("my_list", new PyList(new ArrayList<>())); + assertThat(eagerResolveExpression("my_list.append(deferred ~ foo)").toString()) + .isEqualTo("my_list.append(deferred ~ 'foo')"); + assertThat(eagerResolveExpression("deferred.append(foo)").toString()) + .isEqualTo("deferred.append('foo')"); + assertThat(eagerResolveExpression("deferred[1 + 1] | length").toString()) + .isEqualTo("filter:length.filter(deferred[2], ____int3rpr3t3r____)"); + } + + @Test + public void itHandlesDeferredBracketMethod() throws NoSuchMethodException { + context.put("zero", 0); + context.put("foo", "foo"); + LinkedHashMap map = new LinkedHashMap<>(); + map.put("string", null); + context.put( + "my_list", + new PyList( + Collections.singletonList( + new AbstractCallableMethod("echo", map) { + @Override + public Object doEvaluate( + Map argMap, + Map kwargMap, + List varArgs + ) { + return argMap.get("string"); + } + } + ) + ) + ); + assertThat(eagerResolveExpression("my_list[zero](foo)").toString()) + .isEqualTo("'foo'"); + assertThat(eagerResolveExpression("my_list[zero](deferred ~ foo)").toString()) + .isEqualTo("my_list[0](deferred ~ 'foo')"); + } + + @Test + public void itHandlesOrOperator() { + assertThat( + WhitespaceUtils.unquoteAndUnescape( + eagerResolveExpression("false == true || (true) ? 'yes' : 'no'").toString() + ) + ) + .isEqualTo("yes"); + } + + @Test + public void itSplitsResolvedExpression() { + eagerResolveExpression("['a', 'b']"); + assertThat(context.getResolvedExpressions()) + .containsExactlyInAnyOrder("['a', 'b']", "'a'", "'b'"); + } + + @Test + public void itDoesNotSplitJsonResolvedExpression() { + eagerResolveExpression("{'a': 1, 'b': 2}"); + assertThat(context.getResolvedExpressions()) + .containsExactlyInAnyOrder("{'a': 1, 'b': 2}"); + } + + @Test + public void itDoesNotSplitJsonInArrayResolvedExpression() { + // It should not add the broke JSON `{'a': 1` to resolved expressions. + eagerResolveExpression("[{'a': 1, 'b': 2}]"); + assertThat(context.getResolvedExpressions()) + .containsExactlyInAnyOrder("[{'a': 1, 'b': 2}]"); + } + + @Test + public void itHandlesRandom() { + assertThat(eagerResolveExpression("range(1)|random").toString()) + .isEqualTo("filter:random.filter(range(1), ____int3rpr3t3r____)"); + } + + @Test + public void itDoesntMarkNamedParamsAsDeferredWords() { + EagerExpressionResult result = eagerResolveExpression("range(end=deferred)"); + assertThat(result.toString()).isEqualTo("range(end=deferred)"); + assertThat(result.getDeferredWords()).containsExactlyInAnyOrder("range", "deferred"); + } + + @Test + public void itDoesntMarkDictionaryKeysAsDeferredWords() { + context.put("foo", "foo val"); + EagerExpressionResult result = eagerResolveExpression("{foo: foo, bar:deferred}"); + assertThat(result.toString()).isEqualTo("{foo: 'foo val', bar: deferred}"); + assertThat(result.getDeferredWords()).doesNotContain("foo", "bar"); + } + + @Test + public void itMarksDictionaryKeysAsDeferredWordsIfEvaluated() throws Exception { + JinjavaInterpreter.pushCurrent(getInterpreter(true)); + try { + context.put("foo", "foo val"); + EagerExpressionResult result = eagerResolveExpression("{deferred: foo}"); + assertThat(result.toString()).isEqualTo("{deferred: 'foo val'}"); + assertThat(result.getDeferredWords()).containsExactly("deferred"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") + @Test + public void itIsThreadSafe() throws InterruptedException { + Map map = new HashMap<>(); + map.put("bar", "first"); + AtomicLong sleepTime = new AtomicLong(500L); + context.put("map", map); + context.put("sleep_time", sleepTime); + CompletableFuture first; + synchronized (sleepTime) { + first = CompletableFuture.supplyAsync(this::appendAndSleep); + sleepTime.wait(); + } + sleepTime.set(1L); + map.put("bar", "second"); + CompletableFuture second = CompletableFuture.supplyAsync( + this::appendAndSleep + ); + CompletableFuture.allOf(first, second).join(); + assertThat(first.join().toString()) + .describedAs("First result should say 'first' and sleep for 500ms") + .isEqualTo("deferred && 'first' && 500"); + assertThat(second.join().toString()) // caching would make this say 'first' + .describedAs("Second result should say 'second' and sleep for 1ms") + .isEqualTo("deferred && 'second' && 1"); + } + + private EagerExpressionResult appendAndSleep() { + JinjavaInterpreter.pushCurrent(interpreter); + try { + return eagerResolveExpression("deferred && map.bar && sleeper()"); + } finally { + JinjavaInterpreter.popCurrent(); + } + } + + public static void voidFunction(int nothing) {} + + public static boolean isNull(Object foo, Object bar) { + return foo == null && bar == null; + } + + @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") + public static long sleeper() throws InterruptedException { + AtomicLong atomicSleepTime = (AtomicLong) JinjavaInterpreter + .getCurrent() + .getContext() + .get("sleep_time"); + long sleepTime = atomicSleepTime.get(); + synchronized (atomicSleepTime) { + atomicSleepTime.notify(); + } + Thread.sleep(sleepTime); + return sleepTime; + } + + public static Optional optionally(boolean hasValue) { + return Optional.of(hasValue).filter(Boolean::booleanValue).map(ignored -> "1"); + } + + @Test + public void itCountsBigDecimalAsPrimitive() { + assertThat(EagerExpressionResolver.isResolvableObject(new BigDecimal("2.1E7"))) + .isTrue(); + } + + @Test + public void itCountsOptionalAsResolvable() { + assertThat( + EagerExpressionResolver.isResolvableObject( + ImmutableList.of(Optional.of(123), Optional.empty()) + ) + ) + .isTrue(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/EagerReconstructionUtilsTest.java b/src/test/java/com/hubspot/jinjava/util/EagerReconstructionUtilsTest.java new file mode 100644 index 000000000..a2e6b0b81 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/EagerReconstructionUtilsTest.java @@ -0,0 +1,407 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.LazyExpression; +import com.hubspot.jinjava.interpret.OutputTooBigException; +import com.hubspot.jinjava.lib.fn.MacroFunction; +import com.hubspot.jinjava.lib.fn.eager.EagerMacroFunction; +import com.hubspot.jinjava.lib.tag.eager.DeferredToken; +import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; +import com.hubspot.jinjava.mode.DefaultExecutionMode; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.mode.PreserveRawExecutionMode; +import com.hubspot.jinjava.objects.collections.PyList; +import com.hubspot.jinjava.objects.collections.PyMap; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.DefaultTokenScannerSymbols; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class EagerReconstructionUtilsTest extends BaseInterpretingTest { + + private static final long MAX_OUTPUT_SIZE = 50L; + + @Before + public void eagerSetup() throws Exception { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withMaxOutputSize(MAX_OUTPUT_SIZE) + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itExecutesInChildContextAndTakesNewValue() { + context.put("foo", new PyList(new ArrayList<>())); + EagerExecutionResult result = EagerContextWatcher.executeInChildContext( + (interpreter1 -> { + ((List) interpreter1.getContext().get("foo")).add(1); + return EagerExpressionResult.fromString("function return"); + }), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withTakeNewValue(true) + .withForceDeferredExecutionMode(true) + .withCheckForContextChanges(true) + .build() + ); + + assertThat(context.get("foo")).isEqualTo(ImmutableList.of(1)); + assertThat(context.getDeferredTokens()).isEmpty(); + assertThat(result.getResult().toString()).isEqualTo("function return"); + // This will add an eager token because we normally don't call this method + // unless we're in deferred execution mode. + assertThat(result.getPrefixToPreserveState().toString()) + .isEqualTo("{% set foo = [1] %}"); + } + + @Test + public void itExecutesInChildContextAndDefersNewValue() { + context.put("foo", new ArrayList()); + EagerExecutionResult result = EagerContextWatcher.executeInChildContext( + (interpreter1 -> { + context.put( + "foo", + DeferredValue.instance(interpreter1.getContext().get("foo")) + ); + return EagerExpressionResult.fromString("function return"); + }), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(true) + .withCheckForContextChanges(true) + .build() + ); + assertThat(result.getResult().toString()).isEqualTo("function return"); + assertThat(result.getPrefixToPreserveState().toString()) + .isEqualTo("{% set foo = [] %}"); + assertThat(context.get("foo")).isInstanceOf(DeferredValue.class); + } + + @Test + public void itReconstructsMacroFunctionsFromGlobal() { + Set deferredWords = new HashSet<>(); + deferredWords.add("foo"); + String image = "{% macro foo(bar) %}something{% endmacro %}"; + MacroFunction mockMacroFunction = getMockMacroFunction(image); + context.addGlobalMacro(mockMacroFunction); + String result = EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + deferredWords, + interpreter + ); + assertThat(result).isEqualTo(image); + assertThat(deferredWords).isEmpty(); + } + + @Test + public void itReconstructsMacroFunctionsFromLocal() { + Set deferredWords = new HashSet<>(); + deferredWords.add("local.foo"); + String image = "{% macro foo(bar) %}something{% endmacro %}"; + MacroFunction mockMacroFunction = getMockMacroFunction(image); + Map localAlias = new PyMap(ImmutableMap.of("foo", mockMacroFunction)); + context.put("local", localAlias); + String result = EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + deferredWords, + interpreter + ); + assertThat(result).isEqualTo("{% macro local.foo(bar) %}something{% endmacro %}"); + assertThat(deferredWords).isEmpty(); + } + + @Test + public void itReconstructsVariables() { + Set deferredWords = new HashSet<>(); + deferredWords.add("foo.append"); + context.put("foo", new PyList(new ArrayList<>())); + String result = EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + deferredWords, + interpreter + ); + assertThat(result).isEqualTo("{% set foo = [] %}"); + } + + @Test + public void itDoesntReconstructVariablesInDeferredExecutionMode() { + Set deferredWords = new HashSet<>(); + deferredWords.add("foo.append"); + context.put("foo", new PyList(new ArrayList<>())); + try (InterpreterScopeClosable c = interpreter.enterScope()) { + interpreter.getContext().setDeferredExecutionMode(true); + String result = EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + deferredWords, + interpreter + ); + assertThat(result).isEqualTo(""); + } + } + + @Test + public void itReconstructsVariablesAndMacroFunctions() { + Set deferredWords = new HashSet<>(); + deferredWords.add("bar.append"); + deferredWords.add("foo"); + String image = "{% macro foo(bar) %}something{% endmacro %}"; + MacroFunction mockMacroFunction = getMockMacroFunction(image); + context.addGlobalMacro(mockMacroFunction); + context.put("bar", new PyList(new ArrayList<>())); + String result = EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + deferredWords, + interpreter + ); + assertThat(result) + .isEqualTo("{% macro foo(bar) %}something{% endmacro %}{% set bar = [] %}"); + } + + @Test + public void itBuildsSetTagForDeferredAndRegisters() { + String result = + EagerReconstructionUtils.buildBlockOrInlineSetTagAndRegisterDeferredToken( + "foo", + "bar", + interpreter + ); + assertThat(result).isEqualTo("{% set foo = 'bar' %}"); + assertThat(context.getDeferredTokens()).hasSize(1); + DeferredToken deferredToken = context + .getDeferredTokens() + .stream() + .findAny() + .orElseThrow(RuntimeException::new); + assertThat(deferredToken.getSetDeferredWords()).containsExactly("foo"); + assertThat(deferredToken.getUsedDeferredWords()).isEmpty(); + } + + @Test + public void itBuildsSetTagForDeferredAndDoesntRegister() { + String result = EagerReconstructionUtils.buildBlockOrInlineSetTag( + "foo", + "bar", + interpreter + ); + assertThat(result).isEqualTo("{% set foo = 'bar' %}"); + assertThat(context.getDeferredTokens()).isEmpty(); + } + + @Test + public void itBuildsSetTagForMultipleDeferred() { + Map deferredValuesToSet = ImmutableMap.of("foo", "'bar'", "baz", "2"); + String result = EagerReconstructionUtils.buildSetTag( + deferredValuesToSet, + interpreter, + true + ); + assertThat(result).isEqualTo("{% set foo,baz = 'bar',2 %}"); + assertThat(context.getDeferredTokens()).hasSize(1); + DeferredToken deferredToken = context + .getDeferredTokens() + .stream() + .findAny() + .orElseThrow(RuntimeException::new); + assertThat(deferredToken.getSetDeferredWords()) + .containsExactlyInAnyOrder("foo", "baz"); + assertThat(deferredToken.getUsedDeferredWords()).isEmpty(); + } + + @Test + public void itLimitsSetTagConstruction() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < MAX_OUTPUT_SIZE; i++) { + tooLong.append(i); + } + assertThatThrownBy(() -> + EagerReconstructionUtils.buildBlockOrInlineSetTagAndRegisterDeferredToken( + "foo", + tooLong.toString(), + interpreter + ) + ) + .isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itWrapsInRawTag() { + String toWrap = "{{ foo }}"; + JinjavaConfig preserveRawConfig = BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build(); + assertThat( + EagerReconstructionUtils.wrapInRawIfNeeded( + toWrap, + new JinjavaInterpreter(jinjava, context, preserveRawConfig) + ) + ) + .isEqualTo(String.format("{%% raw %%}%s{%% endraw %%}", toWrap)); + } + + @Test + public void itDoesntWrapInRawTagUnnecessarily() { + String toWrap = "foo"; + JinjavaConfig preserveRawConfig = BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(PreserveRawExecutionMode.instance()) + .build(); + assertThat( + EagerReconstructionUtils.wrapInRawIfNeeded( + toWrap, + new JinjavaInterpreter(jinjava, context, preserveRawConfig) + ) + ) + .isEqualTo(toWrap); + } + + @Test + public void itDoesntWrapInRawTagForDefaultConfig() { + JinjavaConfig defaultConfig = BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .build(); + String toWrap = "{{ foo }}"; + assertThat( + EagerReconstructionUtils.wrapInRawIfNeeded( + toWrap, + new JinjavaInterpreter(jinjava, context, defaultConfig) + ) + ) + .isEqualTo(toWrap); + } + + @Test + public void itWrapsInAutoEscapeTag() { + String toWrap = "
    {{deferred}}
    "; + interpreter.getContext().setAutoEscape(true); + assertThat(EagerReconstructionUtils.wrapInAutoEscapeIfNeeded(toWrap, interpreter)) + .isEqualTo(String.format("{%% autoescape %%}%s{%% endautoescape %%}", toWrap)); + } + + @Test + public void itDoesntWrapInAutoEscapeWhenFalse() { + String toWrap = "
    {{deferred}}
    "; + interpreter.getContext().setAutoEscape(false); + assertThat(EagerReconstructionUtils.wrapInAutoEscapeIfNeeded(toWrap, interpreter)) + .isEqualTo(toWrap); + } + + @Test + public void itReconstructsTheEndOfATagNode() { + TagNode tagNode = getMockTagNode("endif"); + assertThat(EagerReconstructionUtils.reconstructEnd(tagNode)).isEqualTo("{% endif %}"); + tagNode = getMockTagNode("endfor"); + assertThat(EagerReconstructionUtils.reconstructEnd(tagNode)) + .isEqualTo("{% endfor %}"); + } + + @Test + public void itIgnoresMetaContextVariables() { + interpreter + .getContext() + .put(Context.IMPORT_RESOURCE_ALIAS_KEY, DeferredValue.instance()); + assertThat( + EagerReconstructionUtils.reconstructFromContextBeforeDeferring( + Collections.singleton(Context.IMPORT_RESOURCE_ALIAS_KEY), + interpreter + ) + ) + .isEmpty(); + } + + @Test + public void itDiscardsSessionBindings() { + interpreter.getContext().put("foo", "bar"); + EagerExecutionResult withSessionBindings = EagerContextWatcher.executeInChildContext( + eagerInterpreter -> { + interpreter.getContext().put("foo", "foobar"); + return EagerExpressionResult.fromString(""); + }, + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withDiscardSessionBindings(false) + .withCheckForContextChanges(true) + .build() + ); + EagerExecutionResult withoutSessionBindings = + EagerContextWatcher.executeInChildContext( + eagerInterpreter -> { + interpreter.getContext().put("foo", "foobar"); + return EagerExpressionResult.fromString(""); + }, + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withDiscardSessionBindings(true) + .withCheckForContextChanges(true) + .build() + ); + assertThat(withSessionBindings.getSpeculativeBindings()) + .containsEntry("foo", "foobar"); + assertThat(withoutSessionBindings.getSpeculativeBindings()).doesNotContainKey("foo"); + } + + @Test + public void itDoesNotBreakOnNullLazyExpressions() { + interpreter.getContext().put("foo", LazyExpression.of(() -> null, "")); + EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromString(interpreter.render("{% set foo = 'bar' %}")), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withDiscardSessionBindings(false) + .withCheckForContextChanges(true) + .withTakeNewValue(true) + .build() + ); + } + + private EagerMacroFunction getMockMacroFunction(String image) { + interpreter.render(image); + return (EagerMacroFunction) interpreter.getContext().getGlobalMacro("foo"); + } + + private static TagNode getMockTagNode(String endName) { + TagNode mockTagNode = mock(TagNode.class); + when(mockTagNode.getSymbols()).thenReturn(new DefaultTokenScannerSymbols()); + when(mockTagNode.getEndName()).thenReturn(endName); + return mockTagNode; + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/ForLoopTest.java b/src/test/java/com/hubspot/jinjava/util/ForLoopTest.java index f29e8e567..262485438 100644 --- a/src/test/java/com/hubspot/jinjava/util/ForLoopTest.java +++ b/src/test/java/com/hubspot/jinjava/util/ForLoopTest.java @@ -19,7 +19,6 @@ import java.util.ArrayList; import java.util.Iterator; - import org.junit.Before; import org.junit.Test; @@ -28,6 +27,7 @@ public class ForLoopTest { private static final int NULL_VAL = Integer.MIN_VALUE; public static class AIterator implements Iterator { + int i = 0; @Override @@ -38,8 +38,7 @@ public boolean hasNext() { @Override public String next() { i++; - if (i > 6) - return null; + if (i > 6) return null; return "test"; } @@ -47,14 +46,13 @@ public String next() { public void remove() { // nothing } - } private ForLoop loop; @Before - public void setUp() throws Exception { - ArrayList al = new ArrayList(); + public void setUp() { + ArrayList al = new ArrayList<>(); al.add("String"); al.add("true1"); al.add(Boolean.TRUE); @@ -206,5 +204,4 @@ public void test10() { assertEquals(NULL_VAL, loop.getLength()); assertEquals(false, loop.isLast()); } - } diff --git a/src/test/java/com/hubspot/jinjava/util/HelperStringTokenizerTest.java b/src/test/java/com/hubspot/jinjava/util/HelperStringTokenizerTest.java index 517357d75..275074d29 100644 --- a/src/test/java/com/hubspot/jinjava/util/HelperStringTokenizerTest.java +++ b/src/test/java/com/hubspot/jinjava/util/HelperStringTokenizerTest.java @@ -91,7 +91,10 @@ public void test7() { @Test public void test8() { - tk = new HelperStringTokenizer("111 ', \"' \"222\"' ;, ' 333 post.id|add:'444',\"555\",666 555"); + tk = + new HelperStringTokenizer( + "111 ', \"' \"222\"' ;, ' 333 post.id|add:'444',\"555\",666 555" + ); tk.next(); tk.next(); tk.next(); @@ -101,9 +104,38 @@ public void test8() { @Test public void itDoesntReturnTrailingNull() { - assertThat(new HelperStringTokenizer("product in collections.frontpage.products ").splitComma(true).allTokens()) - .containsExactly("product", "in", "collections.frontpage.products") - .doesNotContainNull(); + assertThat( + new HelperStringTokenizer("product in collections.frontpage.products ") + .splitComma(true) + .allTokens() + ) + .containsExactly("product", "in", "collections.frontpage.products") + .doesNotContainNull(); } + @Test + public void itHandlesEscapedQuotesWithinQuotedStrings() { + assertThat( + new HelperStringTokenizer("'hi','y\\'all don\\'t'").splitComma(true).allTokens() + ) + .containsExactly("'hi'", "'y\\'all don\\'t'"); + } + + @Test + public void itHandlesEscapedDoubleQuotesWithinQuotedStrings() { + assertThat( + new HelperStringTokenizer("\"hi\",\"say \\\"hello\\\"\"") + .splitComma(true) + .allTokens() + ) + .containsExactly("\"hi\"", "\"say \\\"hello\\\"\""); + } + + @Test + public void itHandlesEscapedBackslashes() { + assertThat( + new HelperStringTokenizer("'path\\\\to\\file'").splitComma(true).allTokens() + ) + .containsExactly("'path\\\\to\\file'"); + } } diff --git a/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringBuilderTest.java b/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringBuilderTest.java new file mode 100644 index 000000000..0a2bcf59c --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringBuilderTest.java @@ -0,0 +1,33 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.interpret.OutputTooBigException; +import org.junit.Test; + +public class LengthLimitingStringBuilderTest { + + @Test + public void itLimitsStringLength() { + LengthLimitingStringBuilder sb = new LengthLimitingStringBuilder(10); + sb.append("0123456789"); + assertThatThrownBy(() -> sb.append("1")).isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itDoesNotLimitWithZeroLength() { + LengthLimitingStringBuilder sb = new LengthLimitingStringBuilder(0); + sb.append("0123456789"); + } + + @Test + public void itHandlesNullStrings() { + LengthLimitingStringBuilder sb = new LengthLimitingStringBuilder(10); + sb.append(null); + assertThat(sb.toString()).isEqualTo("null"); + LengthLimitingStringBuilder sbLimited = new LengthLimitingStringBuilder(3); + assertThatThrownBy(() -> sbLimited.append(null)) + .isInstanceOf(OutputTooBigException.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringJoinerTest.java b/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringJoinerTest.java new file mode 100644 index 000000000..30cfca4da --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/LengthLimitingStringJoinerTest.java @@ -0,0 +1,35 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.interpret.OutputTooBigException; +import org.junit.Test; + +public class LengthLimitingStringJoinerTest { + + @Test + public void itLimitsStringLength() { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner(10, " "); + joiner.add("0123456789"); + assertThatThrownBy(() -> joiner.add("1")).isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itDoesNotLimitWithZeroLength() { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner(0, " "); + joiner.add("0123456789"); + } + + @Test + public void itLimitsOnAdd() { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner(1, " "); + assertThatThrownBy(() -> joiner.add("123")).isInstanceOf(OutputTooBigException.class); + } + + @Test + public void itIncludesDelimiterInLimit() { + LengthLimitingStringJoiner joiner = new LengthLimitingStringJoiner(5, "123456789"); + joiner.add('a'); + assertThatThrownBy(() -> joiner.add('b')).isInstanceOf(OutputTooBigException.class); + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/ObjectIteratorTest.java b/src/test/java/com/hubspot/jinjava/util/ObjectIteratorTest.java index 667f704c1..f77786b50 100644 --- a/src/test/java/com/hubspot/jinjava/util/ObjectIteratorTest.java +++ b/src/test/java/com/hubspot/jinjava/util/ObjectIteratorTest.java @@ -16,12 +16,18 @@ package com.hubspot.jinjava.util; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - import org.junit.Test; public class ObjectIteratorTest { @@ -70,7 +76,7 @@ public void test5() { @Test public void test6() { - List items = new ArrayList(); + List items = new ArrayList<>(); items.add("hello"); items.add("world"); items.add("jinjava"); @@ -81,8 +87,8 @@ public void test6() { } @Test - public void test7() { - Map items = new HashMap(); + public void testItIteratesOverValues() { + Map items = new HashMap<>(); items.put("ok", 1); items.put(1, "ok"); items.put(2, 2); @@ -90,5 +96,33 @@ public void test7() { items.put("test", new ObjectIteratorTest()); loop = ObjectIterator.getLoop(items); assertEquals(5, loop.getLength()); + assertTrue(items.containsValue(loop.next())); + } + + @Test + public void testItIteratesOverKeys() throws Exception { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withIterateOverMapKeys(true).build() + ) + .build(); + JinjavaInterpreter.pushCurrent( + new JinjavaInterpreter(new Jinjava(), new Context(), config) + ); + + Map items = new HashMap<>(); + items.put("hello", "ok"); + items.put("world", 2); + items.put("jinjava", "ko"); + items.put("asfun", new ObjectIteratorTest()); + try { + loop = ObjectIterator.getLoop(items); + + assertEquals(4, loop.getLength()); + assertTrue(items.containsKey(loop.next())); + } finally { + JinjavaInterpreter.popCurrent(); + } } } diff --git a/src/test/java/com/hubspot/jinjava/util/ObjectTruthValueTest.java b/src/test/java/com/hubspot/jinjava/util/ObjectTruthValueTest.java new file mode 100644 index 000000000..f02f53cfa --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/ObjectTruthValueTest.java @@ -0,0 +1,61 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +public class ObjectTruthValueTest { + + @Test + public void itEvaluatesObjectsWithObjectTruthValue() { + assertThat(ObjectTruthValue.evaluate(new TestObject().setObjectTruthValue(true))) + .isTrue(); + assertThat(ObjectTruthValue.evaluate(new TestObject().setObjectTruthValue(false))) + .isFalse(); + } + + @Test + public void itEvaluatesIntegers() { + checkNumberTruthiness(1, 0); + } + + @Test + public void itEvaluatesDoubles() { + checkNumberTruthiness(0.5, 0.0); + } + + @Test + public void itEvaluatesLongs() { + checkNumberTruthiness(1L, 0L); + } + + @Test + public void itEvaluatesShorts() { + checkNumberTruthiness((short) 1, (short) 0); + } + + @Test + public void itEvaluatesFloats() { + checkNumberTruthiness(0.5f, 0.0f); + } + + private void checkNumberTruthiness(Object a, Object b) { + assertThat(ObjectTruthValue.evaluate(a)).isTrue(); + assertThat(ObjectTruthValue.evaluate(b)).isFalse(); + } + + private class TestObject implements HasObjectTruthValue { + + private boolean objectTruthValue = false; + + public TestObject setObjectTruthValue(boolean objectTruthValue) { + this.objectTruthValue = objectTruthValue; + return this; + } + + @Override + public boolean getObjectTruthValue() { + return objectTruthValue; + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/RenderLimitUtilsTest.java b/src/test/java/com/hubspot/jinjava/util/RenderLimitUtilsTest.java new file mode 100644 index 000000000..1e0bca06d --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/util/RenderLimitUtilsTest.java @@ -0,0 +1,47 @@ +package com.hubspot.jinjava.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import org.junit.Test; + +public class RenderLimitUtilsTest { + + @Test + public void itPicksLowerLimitWhenConfigIsSet() { + assertThat( + RenderLimitUtils.clampProvidedRenderLimitToConfig(100, configWithOutputSize(10)) + ) + .isEqualTo(10); + } + + @Test + public void itKeepsConfigLimitWhenConfigSetAndUnlimitedProvided() { + assertThat( + RenderLimitUtils.clampProvidedRenderLimitToConfig(0, configWithOutputSize(10)) + ) + .isEqualTo(10); + assertThat( + RenderLimitUtils.clampProvidedRenderLimitToConfig(-10, configWithOutputSize(10)) + ) + .isEqualTo(10); + } + + @Test + public void itUsesProvidedLimitWhenConfigIsUnlimited() { + assertThat( + RenderLimitUtils.clampProvidedRenderLimitToConfig(10, configWithOutputSize(0)) + ) + .isEqualTo(10); + + assertThat( + RenderLimitUtils.clampProvidedRenderLimitToConfig(10, configWithOutputSize(-10)) + ) + .isEqualTo(10); + } + + private JinjavaConfig configWithOutputSize(long size) { + return BaseJinjavaTest.newConfigBuilder().withMaxOutputSize(size).build(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/util/ScopeMapTest.java b/src/test/java/com/hubspot/jinjava/util/ScopeMapTest.java index f947fb19b..66f064568 100644 --- a/src/test/java/com/hubspot/jinjava/util/ScopeMapTest.java +++ b/src/test/java/com/hubspot/jinjava/util/ScopeMapTest.java @@ -1,15 +1,14 @@ package com.hubspot.jinjava.util; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.entry; +import com.google.common.collect.ImmutableMap; import java.util.Map; - import org.junit.Before; import org.junit.Test; -import com.google.common.collect.ImmutableMap; - public class ScopeMapTest { Map a, b, c; @@ -23,7 +22,7 @@ public void setup() { @Test public void noParent() { - ScopeMap map = new ScopeMap(); + ScopeMap map = new ScopeMap<>(); map.put("a", "v"); assertThat(map).contains(entry("a", "v")); assertThat(map).containsKeys("a"); @@ -33,8 +32,9 @@ public void noParent() { @Test public void withParent() { ScopeMap map = new ScopeMap( - new ScopeMap( - new ScopeMap(null, a), b), c); + new ScopeMap<>(new ScopeMap<>(null, a), b), + c + ); assertThat(map.get("a1")).isEqualTo("vc1"); assertThat(map.get("a2")).isEqualTo("vb2"); @@ -49,7 +49,29 @@ public void withParent() { assertThat(map).hasSize(4); assertThat(map.keySet()).containsOnly("a1", "a2", "a3", "a4"); assertThat(map.values()).containsOnly("vc1", "vb2", "vc3", "vc4"); - assertThat(map).containsOnly(entry("a1", "vc1"), entry("a2", "vb2"), entry("a3", "vc3"), entry("a4", "vc4")); + assertThat(map) + .containsOnly( + entry("a1", "vc1"), + entry("a2", "vb2"), + entry("a3", "vc3"), + entry("a4", "vc4") + ); } + @SuppressWarnings("CollectionAddedToSelf") + @Test + public void itDisallowsPuttingItself() { + ScopeMap map = new ScopeMap<>(); + assertThatThrownBy(() -> map.put("foo", map)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void itDisallowsPuttingAllWithItselfAsValue() { + ScopeMap map1 = new ScopeMap<>(); + ScopeMap map2 = new ScopeMap<>(); + map2.put("map1", map1); + assertThatThrownBy(() -> map1.putAll(map2)) + .isInstanceOf(IllegalArgumentException.class); + } } diff --git a/src/test/java/com/hubspot/jinjava/util/WhitespaceUtilsTest.java b/src/test/java/com/hubspot/jinjava/util/WhitespaceUtilsTest.java index cb4a45f62..de2751514 100644 --- a/src/test/java/com/hubspot/jinjava/util/WhitespaceUtilsTest.java +++ b/src/test/java/com/hubspot/jinjava/util/WhitespaceUtilsTest.java @@ -1,6 +1,7 @@ package com.hubspot.jinjava.util; import static com.hubspot.jinjava.util.WhitespaceUtils.endsWith; +import static com.hubspot.jinjava.util.WhitespaceUtils.isExpressionQuoted; import static com.hubspot.jinjava.util.WhitespaceUtils.startsWith; import static com.hubspot.jinjava.util.WhitespaceUtils.unwrap; import static org.assertj.core.api.Assertions.assertThat; @@ -35,4 +36,30 @@ public void testUnwrap() { assertThat(unwrap("\t foobar\n", "", "")).isEqualTo("foobar"); } + @Test + public void itKnowsWhenAnExpressionIsQuoted() { + assertThat(isExpressionQuoted("'foo'")).isTrue(); + assertThat(isExpressionQuoted("\"foo\"")).isTrue(); + assertThat(isExpressionQuoted("'foo\\'")).isFalse(); + assertThat(isExpressionQuoted("'foo\\\\'")).isTrue(); + assertThat(isExpressionQuoted("foo")).isFalse(); + assertThat(isExpressionQuoted("'foo'|lower ~ 'bar'")).isFalse(); + assertThat(isExpressionQuoted("'foo\" and bar'")).isTrue(); + assertThat(isExpressionQuoted("\"foo and bar\"")).isTrue(); + assertThat(isExpressionQuoted("\"foo 'and' bar\"")).isTrue(); + assertThat(isExpressionQuoted("\"foo 'and' bar'")).isFalse(); + } + + @Test + public void itKnowsUntrimmedExpressionIsQuoted() { + assertThat(isExpressionQuoted(" 'foo'")).isTrue(); + assertThat(isExpressionQuoted("'foo' ")).isTrue(); + assertThat(isExpressionQuoted(" 'foo' ")).isTrue(); + } + + @Test + public void itDoesntCountSingleQuoteChar() { + assertThat(isExpressionQuoted("'")).isFalse(); + assertThat(isExpressionQuoted("\" ")).isFalse(); + } } diff --git a/src/test/resources/deferred/deferred-macro.jinja b/src/test/resources/deferred/deferred-macro.jinja new file mode 100644 index 000000000..d6b61491b --- /dev/null +++ b/src/test/resources/deferred/deferred-macro.jinja @@ -0,0 +1,11 @@ +{%- macro inc_padding(width) -%} +{%- set padding = padding + width -%} +{{padding}} +{%- endmacro -%} +{{ padding }}, +{%- set padding = inc_padding(added_padding) | int -%} +{{ padding }}, +{%- set padding = inc_padding(deferred) | int -%} +{{ padding}}, +{%- set padding = inc_padding(added_padding) | int -%} +{{ padding }} diff --git a/src/test/resources/deferred/deferred-map-access.jinja b/src/test/resources/deferred/deferred-map-access.jinja new file mode 100644 index 000000000..09150fc0e --- /dev/null +++ b/src/test/resources/deferred/deferred-map-access.jinja @@ -0,0 +1,6 @@ +{% set reference = deferredValue %} +{% if reference == 'resolved' %} + {% set varSetInside = imported.map[deferredValue2.nonexistentprop] %} +{{ deferredValue2.nonexistentprop }} +{% endif %} +{{ varSetInside }} diff --git a/src/test/resources/deferred/set-in-deferred.jinja b/src/test/resources/deferred/set-in-deferred.jinja new file mode 100644 index 000000000..8da562112 --- /dev/null +++ b/src/test/resources/deferred/set-in-deferred.jinja @@ -0,0 +1,7 @@ +{% set reference = deferredValue %} +{% if reference == 'resolved' %} + {% set varSetInside = 'set inside' %} + {% set a, b, c = 'x','y','z' %} + {{ a ~ b ~ c }} +{% endif %} +{{ varSetInside }} diff --git a/src/test/resources/deferred/set-within-lower-scope-twice.jinja b/src/test/resources/deferred/set-within-lower-scope-twice.jinja new file mode 100644 index 000000000..fa1c3ed45 --- /dev/null +++ b/src/test/resources/deferred/set-within-lower-scope-twice.jinja @@ -0,0 +1,12 @@ +{%- for item in resolved -%} + {%- set varSetInside = 'inside first scope' -%} + {%- if deferredValue -%} + {{ varSetInside }} + {%- endif -%} +{%- endfor -%} +{%- for item in resolved -%} + {%- set varSetInside = 'inside first scope2' -%} + {%- if deferredValue -%} + {{ varSetInside }} + {%- endif -%} +{%- endfor -%} diff --git a/src/test/resources/deferred/set-within-lower-scope.jinja b/src/test/resources/deferred/set-within-lower-scope.jinja new file mode 100644 index 000000000..6f2420955 --- /dev/null +++ b/src/test/resources/deferred/set-within-lower-scope.jinja @@ -0,0 +1,6 @@ +{% for item in resolved %} + {% set varSetInside = 'inside first scope' %} + {% if deferredValue %} + {{ varSetInside }} + {% endif %} +{% endfor %} diff --git a/src/test/resources/deferred/vars-in-deferred-node.jinja b/src/test/resources/deferred/vars-in-deferred-node.jinja new file mode 100644 index 000000000..3a591808a --- /dev/null +++ b/src/test/resources/deferred/vars-in-deferred-node.jinja @@ -0,0 +1,9 @@ +{%- set varUsedInForScope = 'outside if statement' -%} +{%- for item in resolved -%} + {%- if deferredValue -%} +{{ varUsedInForScope }} +{%- set varUsedInForScope = ' entered if statement' -%} +{%- endif -%} + +{{ varUsedInForScope }} +{%- endfor -%} diff --git a/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.expected.jinja b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.expected.jinja new file mode 100644 index 000000000..830e009cf --- /dev/null +++ b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.expected.jinja @@ -0,0 +1,2 @@ +B: ['resolved', 'B']. +A: ['a', 'b', 'A']. diff --git a/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.jinja b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.jinja new file mode 100644 index 000000000..07854f3c5 --- /dev/null +++ b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.expected.jinja @@ -0,0 +1,12 @@ +{% set a_list = ['a', 'b'] %}\ +{% for __ignored__ in [0] %} +{% set b_list = a_list %}\ +{% if deferred %} +{% set b_list = [deferred] %} +{% endif %}\ +{% do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}\ +.{% endfor %}\ +{% do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}\ +. diff --git a/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.jinja b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.jinja new file mode 100644 index 000000000..7d810e58a --- /dev/null +++ b/src/test/resources/eager/allows-deferred-lazy-reference-to-get-overridden/test.jinja @@ -0,0 +1,13 @@ +{% set a_list = [] %} +{%- do a_list.append('a') %} +{%- for i in range(1) %} +{%- set b_list = a_list %} +{%- do b_list.append('b') %} +{% if deferred %} +{% set b_list = [deferred] %} +{% endif %} +{%- do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}. +{%- endfor %} +{%- do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}. diff --git a/src/test/resources/eager/allows-meta-context-var-overriding/test.expected.jinja b/src/test/resources/eager/allows-meta-context-var-overriding/test.expected.jinja new file mode 100644 index 000000000..061a088d4 --- /dev/null +++ b/src/test/resources/eager/allows-meta-context-var-overriding/test.expected.jinja @@ -0,0 +1,11 @@ +0 +META +{% for meta in deferred %} +{{ meta }}\ +{% endfor %} +META +{% set meta = [] %}\ +{% if deferred %} +{% do meta.append(1) %} +{% endif %} +{{ meta }} diff --git a/src/test/resources/eager/allows-meta-context-var-overriding/test.jinja b/src/test/resources/eager/allows-meta-context-var-overriding/test.jinja new file mode 100644 index 000000000..74ac4f405 --- /dev/null +++ b/src/test/resources/eager/allows-meta-context-var-overriding/test.jinja @@ -0,0 +1,13 @@ +{% for meta in [0] %} +{{ meta }} +{%- endfor %} +{{ meta }} +{% for meta in deferred %} +{{ meta }} +{%- endfor %} +{{ meta }} +{%- set meta = [] %} +{% if deferred %} +{% do meta.append(1) %} +{% endif %} +{{ meta }} diff --git a/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.expected.jinja b/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.expected.jinja new file mode 100644 index 000000000..82af21f61 --- /dev/null +++ b/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.expected.jinja @@ -0,0 +1 @@ +[[0, [1, [2, [3, [4, [5, [6, [7, [8, [9, [10, [11, [12, [13, [14, [15, [16, [17, [18, [19]]]]]]]]]]]]]]]]]]]], 'END'] diff --git a/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.jinja b/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.jinja new file mode 100644 index 000000000..82ef7ee91 --- /dev/null +++ b/src/test/resources/eager/allows-modification-in-resolved-for-loop/test.jinja @@ -0,0 +1,12 @@ +{% set list = [] %} +{% set temp = list %} +{# create an object that is too deep for us to reconstruct via check on EagerExpressionResolver.isResolvableObject #} +{% for i in range(20) %} +{% set temp2 = [i] %} +{% do temp.append(temp2) %} +{% set temp = temp2 %} +{% endfor %} +{% for j in range(1) %} +{% do list.append('END') %} +{% endfor %} +{{ list }} diff --git a/src/test/resources/eager/allows-variable-sharing-alias-name/filters.jinja b/src/test/resources/eager/allows-variable-sharing-alias-name/filters.jinja new file mode 100644 index 000000000..85d767628 --- /dev/null +++ b/src/test/resources/eager/allows-variable-sharing-alias-name/filters.jinja @@ -0,0 +1,4 @@ +{% set foo = 123 %} +{% set bar = deferred %} +{% set filters = {} %} +{% do filters.update(deferred) %} diff --git a/src/test/resources/eager/allows-variable-sharing-alias-name/test.expected.jinja b/src/test/resources/eager/allows-variable-sharing-alias-name/test.expected.jinja new file mode 100644 index 000000000..fd7a323fa --- /dev/null +++ b/src/test/resources/eager/allows-variable-sharing-alias-name/test.expected.jinja @@ -0,0 +1,17 @@ +{% do %}\ +{% set __temp_meta_current_path_200847175__,current_path = current_path,'eager/allows-variable-sharing-alias-name/filters.jinja' %}\ +{% set __temp_meta_import_alias_854547461__ = {} %}\ +{% for __ignored__ in [0] %} +{% set bar = deferred %}\ +{% do __temp_meta_import_alias_854547461__.update({'bar': bar}) %} + +{% set filters = {} %}\ +{% do __temp_meta_import_alias_854547461__.update({'filters': filters}) %}\ +{% do filters.update(deferred) %} +{% do __temp_meta_import_alias_854547461__.update({'bar': bar,'foo': 123,'import_resource_path': 'eager/allows-variable-sharing-alias-name/filters.jinja','filters': filters}) %}\ +{% endfor %}\ +{% set filters = __temp_meta_import_alias_854547461__ %}\ +{% set current_path,__temp_meta_current_path_200847175__ = __temp_meta_current_path_200847175__,null %}\ +{% enddo %} + +{{ filters }} diff --git a/src/test/resources/eager/allows-variable-sharing-alias-name/test.jinja b/src/test/resources/eager/allows-variable-sharing-alias-name/test.jinja new file mode 100644 index 000000000..87fd7dc1d --- /dev/null +++ b/src/test/resources/eager/allows-variable-sharing-alias-name/test.jinja @@ -0,0 +1,3 @@ +{% import './filters.jinja' as filters %} + +{{ filters }} diff --git a/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.expected.jinja b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.expected.jinja new file mode 100644 index 000000000..10e06ff9a --- /dev/null +++ b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.expected.jinja @@ -0,0 +1,3 @@ +L0: ['a'] +L1: ['b'] +L2: ['c', 'resolved'] diff --git a/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.jinja b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.jinja new file mode 100644 index 000000000..804116b2a --- /dev/null +++ b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.expected.jinja @@ -0,0 +1,15 @@ +{% do %}\ +{% set list1 = ['b'] %}\ +{% set list0 = ['a'] %}\ +{% set list2 = ['c'] %} + + +{% set list2 = ['c'] %}\ +{% do list2.append(deferred) %} +{% unless deferred %} +{% set list0 = ['a', 'a'] %} +{% endunless %} +{% enddo %} +L0: {{ list0 }} +L1: ['b'] +L2: {{ list2 }} \ No newline at end of file diff --git a/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.jinja b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.jinja new file mode 100644 index 000000000..f9566c49d --- /dev/null +++ b/src/test/resources/eager/commits-variables-from-do-tag-when-partially-resolved/test.jinja @@ -0,0 +1,11 @@ +{% set list0 = ['a'] %}{% do %} +{% set list1 = ['b'] %} +{% set list2 = ['c'] %} +{% do list2.append(deferred) %} +{% unless deferred %} +{% set list0 = ['a', 'a'] %} +{% endunless %} +{% enddo %} +L0: {{ list0 }} +L1: {{ list1 }} +L2: {{ list2 }} diff --git a/src/test/resources/eager/correctly-defers-with-multiple-loops/test.expected.jinja b/src/test/resources/eager/correctly-defers-with-multiple-loops/test.expected.jinja new file mode 100644 index 000000000..7d11ac0a1 --- /dev/null +++ b/src/test/resources/eager/correctly-defers-with-multiple-loops/test.expected.jinja @@ -0,0 +1,11 @@ +{% set my_list = [] %}\ +{% for __ignored__ in [0] %} +{% for j in deferred %} +{% do my_list.append(0) %} +{% endfor %} + +{% for j in deferred %} +{% do my_list.append(1) %} +{% endfor %} +{% endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/correctly-defers-with-multiple-loops/test.jinja b/src/test/resources/eager/correctly-defers-with-multiple-loops/test.jinja new file mode 100644 index 000000000..4fcea761c --- /dev/null +++ b/src/test/resources/eager/correctly-defers-with-multiple-loops/test.jinja @@ -0,0 +1,7 @@ +{% set my_list = [] %} +{% for i in range(2) %} +{% for j in deferred %} +{% do my_list.append(i) %} +{% endfor %} +{% endfor %} +{{ my_list }} \ No newline at end of file diff --git a/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.expected.jinja b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.expected.jinja new file mode 100644 index 000000000..af8e33a85 --- /dev/null +++ b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.expected.jinja @@ -0,0 +1,7 @@ +resolved + +macro 1 + +resolved1 + +macro2 diff --git a/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.jinja b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.jinja new file mode 100644 index 000000000..4a4a1459f --- /dev/null +++ b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.expected.jinja @@ -0,0 +1,14 @@ +{% macro repeat(val) %} +{{ val }} +{{ caller() }} +{% endmacro %}\ +{% call repeat(deferred) %} +macro 1 +{% macro repeat(val) %} +{{ val }} +{{ caller() }} +{% endmacro %}\ +{% call repeat(deferred + 1) %} +macro2 +{% endcall %} +{% endcall %} diff --git a/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.jinja b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.jinja new file mode 100644 index 000000000..6229ecbe6 --- /dev/null +++ b/src/test/resources/eager/defers-call-tag-with-deferred-argument/test.jinja @@ -0,0 +1,12 @@ +{% macro repeat(val) %} +{{ val }} +{{ caller() }} +{% endmacro %} + + +{% call repeat(deferred) %} +macro 1 +{% call repeat(deferred + 1) %} +macro2 +{% endcall %} +{% endcall %} diff --git a/src/test/resources/eager/defers-caller/test.expected.expected.jinja b/src/test/resources/eager/defers-caller/test.expected.expected.jinja new file mode 100644 index 000000000..3a57de78e --- /dev/null +++ b/src/test/resources/eager/defers-caller/test.expected.expected.jinja @@ -0,0 +1,2 @@ +Jack says: +How do I get a foo? diff --git a/src/test/resources/eager/defers-caller/test.expected.jinja b/src/test/resources/eager/defers-caller/test.expected.jinja new file mode 100644 index 000000000..79657d9af --- /dev/null +++ b/src/test/resources/eager/defers-caller/test.expected.jinja @@ -0,0 +1,6 @@ +{% for __ignored__ in [0] %}\ +Jack says: +{% for __ignored__ in [0] %}\ +How do I get a {{ deferred }}\ +?{% endfor %}\ +{% endfor %} diff --git a/src/test/resources/eager/defers-caller/test.jinja b/src/test/resources/eager/defers-caller/test.jinja new file mode 100644 index 000000000..5ddfde48b --- /dev/null +++ b/src/test/resources/eager/defers-caller/test.jinja @@ -0,0 +1,7 @@ +{%- macro says_something(person) -%} +{{ person }} says: +{{ caller() }} +{%- endmacro -%} +{%- call says_something('Jack') -%} +How do I get a {{ deferred }}? +{%- endcall -%} diff --git a/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.expected.jinja b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.expected.jinja new file mode 100644 index 000000000..14c627b1f --- /dev/null +++ b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.expected.jinja @@ -0,0 +1,6 @@ +1 + + + +Bar: [1, 2] +Foo: 2 diff --git a/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.jinja b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.jinja new file mode 100644 index 000000000..1ba5b9f04 --- /dev/null +++ b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.expected.jinja @@ -0,0 +1,10 @@ +1 +{% set bar = [1] %}\ +{% set foo = '1' %}\ +{% if deferred %} +{% set foo %}\ +2{% do bar.append(2) %}\ +{% endset %} +{% endif %} +Bar: {{ bar }} +Foo: {{ foo }} diff --git a/src/test/resources/eager/defers-changes-within-deferred-set-block/test.jinja b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.jinja new file mode 100644 index 000000000..181194ef0 --- /dev/null +++ b/src/test/resources/eager/defers-changes-within-deferred-set-block/test.jinja @@ -0,0 +1,12 @@ +{% set bar = [] %} +{% set foo -%} +1{% do bar.append(1) %} +{%- endset %} +{{ foo }} +{% if deferred %} +{% set foo -%} +2{% do bar.append(2) %} +{%- endset %} +{% endif %} +Bar: {{ bar }} +Foo: {{ foo }} diff --git a/src/test/resources/eager/defers-eager-child-scoped-vars/test.expected.jinja b/src/test/resources/eager/defers-eager-child-scoped-vars/test.expected.jinja new file mode 100644 index 000000000..70a3c4e05 --- /dev/null +++ b/src/test/resources/eager/defers-eager-child-scoped-vars/test.expected.jinja @@ -0,0 +1,5 @@ +{% if deferred %} +{% set foo = 1 %} +1 +{% endif %} +We don't know if someone wanted to access {{ foo }} here! diff --git a/src/test/resources/eager/defers-eager-child-scoped-vars/test.jinja b/src/test/resources/eager/defers-eager-child-scoped-vars/test.jinja new file mode 100644 index 000000000..4a48fc9a3 --- /dev/null +++ b/src/test/resources/eager/defers-eager-child-scoped-vars/test.jinja @@ -0,0 +1,5 @@ +{% if deferred %} +{% set foo = 1 %} +{{ foo }} +{% endif %} +We don't know if someone wanted to access {{ foo }} here! diff --git a/src/test/resources/eager/defers-ifchanged/test.expected.jinja b/src/test/resources/eager/defers-ifchanged/test.expected.jinja new file mode 100644 index 000000000..96954eccd --- /dev/null +++ b/src/test/resources/eager/defers-ifchanged/test.expected.jinja @@ -0,0 +1,5 @@ +{% for item in deferred %} +{% ifchanged item %} +{{ item + 42 }} +{% endifchanged %} +{% endfor %} diff --git a/src/test/resources/eager/defers-ifchanged/test.jinja b/src/test/resources/eager/defers-ifchanged/test.jinja new file mode 100644 index 000000000..8fdc60530 --- /dev/null +++ b/src/test/resources/eager/defers-ifchanged/test.jinja @@ -0,0 +1,5 @@ +{% for item in deferred %} +{% ifchanged item %} +{{ item + (6 * 7) }} +{% endifchanged %} +{% endfor %} diff --git a/src/test/resources/eager/defers-large-loop/test.expected.jinja b/src/test/resources/eager/defers-large-loop/test.expected.jinja new file mode 100644 index 000000000..8d88c4815 --- /dev/null +++ b/src/test/resources/eager/defers-large-loop/test.expected.jinja @@ -0,0 +1,24 @@ +{% for __ignored__ in [0] %} +Small 0 {{ deferred }} {{ deferred ~ 0 }} + +Small 1 {{ deferred }} {{ deferred ~ 1 }} + +Small 2 {{ deferred }} {{ deferred ~ 2 }} + +Small 3 {{ deferred }} {{ deferred ~ 3 }} + +Small 4 {{ deferred }} {{ deferred ~ 4 }} + +Small 5 {{ deferred }} {{ deferred ~ 5 }} + +Small 6 {{ deferred }} {{ deferred ~ 6 }} + +Small 7 {{ deferred }} {{ deferred ~ 7 }} + +Small 8 {{ deferred }} {{ deferred ~ 8 }} + +Small 9 {{ deferred }} {{ deferred ~ 9 }} +{% endfor %} +{% for i in range(600) %} +Big {{ i }} {{ deferred }} {{ deferred ~ i }} +{% endfor %} \ No newline at end of file diff --git a/src/test/resources/eager/defers-large-loop/test.jinja b/src/test/resources/eager/defers-large-loop/test.jinja new file mode 100644 index 000000000..82c4e671f --- /dev/null +++ b/src/test/resources/eager/defers-large-loop/test.jinja @@ -0,0 +1,6 @@ +{% for i in range(10) %} +Small {{ i }} {{ deferred }} {{ deferred ~ i }} +{% endfor %} +{% for i in range(500 + 100) %} +Big {{ i }} {{ deferred }} {{ deferred ~ i }} +{% endfor %} diff --git a/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.expected.jinja b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.expected.jinja new file mode 100644 index 000000000..a1a53b533 --- /dev/null +++ b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.expected.jinja @@ -0,0 +1,3 @@ +a + +b diff --git a/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.jinja b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.jinja new file mode 100644 index 000000000..33c36f300 --- /dev/null +++ b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.expected.jinja @@ -0,0 +1,17 @@ +{% for __ignored__ in [0] %} +{% macro render(content, query) %}\ +{% if query %}\ +{{ content.foo }}\ +{% endif %}\ +{% endmacro %}\ +{% set content = {'foo': 'a'} %}\ +{{ render(content, deferred) }} + +{% macro render(content, query) %}\ +{% if query %}\ +{{ content.foo }}\ +{% endif %}\ +{% endmacro %}\ +{% set content = {'foo': 'b'} %}\ +{{ render(content, deferred) }} +{% endfor %} diff --git a/src/test/resources/eager/defers-loop-setting-meta-context-var/test.jinja b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.jinja new file mode 100644 index 000000000..539816cc5 --- /dev/null +++ b/src/test/resources/eager/defers-loop-setting-meta-context-var/test.jinja @@ -0,0 +1,11 @@ +{% macro render(content, query) -%} +{%- if query -%} +{{ content.foo }} +{%- endif -%} +{% endmacro %} + +{% set looper = [{'foo': 'a'}, {'foo': 'b'}] %} +{% for content in looper %} +{{ render(content, deferred) }} +{% endfor %} + diff --git a/src/test/resources/eager/defers-macro-for-do-and-print/test.expected.jinja b/src/test/resources/eager/defers-macro-for-do-and-print/test.expected.jinja new file mode 100644 index 000000000..7bc44be6f --- /dev/null +++ b/src/test/resources/eager/defers-macro-for-do-and-print/test.expected.jinja @@ -0,0 +1,15 @@ +Is ([]), +Macro: [10] +Is ([10]),{% set my_list = [10] %}\ +{% macro macro_append(num) %}\ +{% do my_list.append(num) %}\ +Macro: {{ my_list }}\ +{% endmacro %}\ +{% do macro_append(deferred) %} +Is ({{ my_list }}\ +), +{% macro macro_append(num) %}\ +{% do my_list.append(num) %}\ +Macro: {{ my_list }}\ +{% endmacro %}\ +{% print macro_append(deferred2) %} diff --git a/src/test/resources/eager/defers-macro-for-do-and-print/test.jinja b/src/test/resources/eager/defers-macro-for-do-and-print/test.jinja new file mode 100644 index 000000000..2860ccf22 --- /dev/null +++ b/src/test/resources/eager/defers-macro-for-do-and-print/test.jinja @@ -0,0 +1,9 @@ +{%- macro macro_append(num) -%} +{%- do my_list.append(num) -%}Macro: {{ my_list }} +{%- endmacro -%} +Is ({{ my_list }}), +{% print macro_append(first) %} +Is ({{ my_list }}), +{%- do macro_append(deferred) %} +Is ({{ my_list }}), +{% print macro_append(deferred2) %} diff --git a/src/test/resources/eager/defers-macro-in-expression/test.expected.expected.jinja b/src/test/resources/eager/defers-macro-in-expression/test.expected.expected.jinja new file mode 100644 index 000000000..9d69d5c9e --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-expression/test.expected.expected.jinja @@ -0,0 +1,3 @@ +2 +6 +10 diff --git a/src/test/resources/eager/defers-macro-in-expression/test.expected.jinja b/src/test/resources/eager/defers-macro-in-expression/test.expected.jinja new file mode 100644 index 000000000..7443a94eb --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-expression/test.expected.jinja @@ -0,0 +1,10 @@ +2 +{% macro plus(foo, add) %}\ +{{ foo + (filter:int.filter(add, ____int3rpr3t3r____)) }}\ +{% endmacro %}\ +{{ plus(deferred, 1.1) }}\ +{% set deferred = deferred + 2 %} +{% macro plus(foo, add) %}\ +{{ foo + (filter:int.filter(add, ____int3rpr3t3r____)) }}\ +{% endmacro %}\ +{{ plus(deferred, 3.1) }} \ No newline at end of file diff --git a/src/test/resources/eager/defers-macro-in-expression/test.jinja b/src/test/resources/eager/defers-macro-in-expression/test.jinja new file mode 100644 index 000000000..ff98ca0a7 --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-expression/test.jinja @@ -0,0 +1,7 @@ +{%- macro plus(foo, add) -%} +{{ foo + (add|int) }} +{%- endmacro -%} +{{ plus(1, 1) }} +{{ plus(deferred, 1.1) }} +{%- set deferred = deferred + 2 %} +{{ plus(deferred, 3.1) }} diff --git a/src/test/resources/eager/defers-macro-in-for/test.expected.jinja b/src/test/resources/eager/defers-macro-in-for/test.expected.jinja new file mode 100644 index 000000000..3311b8714 --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-for/test.expected.jinja @@ -0,0 +1,8 @@ +{% set my_list = [] %}\ +{% macro macro_append(num) %}\ +{% do my_list.append(num) %}\ +{{ my_list }}\ +{% endmacro %}\ +{% for item in filter:split.filter(macro_append(deferred), ____int3rpr3t3r____, ',', 2) %} +{{ item }} +{% endfor %} diff --git a/src/test/resources/eager/defers-macro-in-for/test.jinja b/src/test/resources/eager/defers-macro-in-for/test.jinja new file mode 100644 index 000000000..dcc3709c5 --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-for/test.jinja @@ -0,0 +1,6 @@ +{%- macro macro_append(num) -%} +{%- do my_list.append(num) -%}{{ my_list }}{# is a string #} +{%- endmacro -%} +{% for item in macro_append(deferred)|split(',',2) %} +{{ item }} +{% endfor %} diff --git a/src/test/resources/eager/defers-macro-in-if/test.expected.jinja b/src/test/resources/eager/defers-macro-in-if/test.expected.jinja new file mode 100644 index 000000000..67f28d9e4 --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-if/test.expected.jinja @@ -0,0 +1,8 @@ +{% set my_list = [] %}\ +{% macro macro_append(num) %}\ +{% do my_list.append(num) %}\ +{{ my_list }}\ +{% endmacro %}\ +{% if [] == filter:split.filter(macro_append(deferred), ____int3rpr3t3r____, ',', 2) %} +{{ my_list }} +{% endif %} diff --git a/src/test/resources/eager/defers-macro-in-if/test.jinja b/src/test/resources/eager/defers-macro-in-if/test.jinja new file mode 100644 index 000000000..3513426de --- /dev/null +++ b/src/test/resources/eager/defers-macro-in-if/test.jinja @@ -0,0 +1,6 @@ +{%- macro macro_append(num) -%} +{%- do my_list.append(num) -%}{{ my_list }}{# is a string #} +{%- endmacro -%} +{% if my_list == macro_append(deferred)|split(',',2) %} +{{ my_list }} +{% endif %} diff --git a/src/test/resources/eager/defers-on-immutable-mode/test.expected.jinja b/src/test/resources/eager/defers-on-immutable-mode/test.expected.jinja new file mode 100644 index 000000000..21cb364d5 --- /dev/null +++ b/src/test/resources/eager/defers-on-immutable-mode/test.expected.jinja @@ -0,0 +1,13 @@ +{% set foo = 1 %}\ +{% if deferred %} +{% set foo = 2 %} +{% else %} +{% set foo = 3 %} +{% endif %} +{{ foo }} + +{% for __ignored__ in [0] %}\ +{% set bar = 1 + deferred %} +{% set bar = bar + deferred %} +{% endfor %}\ +1 diff --git a/src/test/resources/eager/defers-on-immutable-mode/test.jinja b/src/test/resources/eager/defers-on-immutable-mode/test.jinja new file mode 100644 index 000000000..e2c78ae83 --- /dev/null +++ b/src/test/resources/eager/defers-on-immutable-mode/test.jinja @@ -0,0 +1,13 @@ +{% set foo = 1 %} +{% if deferred %} +{% set foo = foo + 1 %} +{% else %} +{% set foo = foo + 2 %} +{% endif %} +{{ foo }} + +{% set bar = 1 %} +{%- for item in range(0,2) %} +{%- set bar = bar + deferred %} +{% endfor -%} +{{ bar }} diff --git a/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/base.jinja b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/base.jinja new file mode 100644 index 000000000..844a1e631 --- /dev/null +++ b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/base.jinja @@ -0,0 +1,12 @@ +{% set tracker_base = '1_base' %} + + +-----Pre-First----- +{% block first -%} +{%- endblock %} +-----Post-First----- +-----Pre-Second----- +{% block second -%} +{%- endblock %} +-----Post-Second----- +We aren't deferring tracker base.{# This message WILL show up in final output #} diff --git a/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/middle.jinja b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/middle.jinja new file mode 100644 index 000000000..b0a0c6aee --- /dev/null +++ b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/middle.jinja @@ -0,0 +1,13 @@ +{% extends '../../eager/does-not-defer-block-when-only-middle-defers/base.jinja' %} +{% set tracker_middle = '2_middle' %} +{% block first %} +I WON'T SHOW UP +{% endblock %} + +{% block second %} +tracker_base is '1_base': {{ tracker_base }}? {{ tracker_base == '1_base' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{% endblock %} +Deferring tracker middle.{# This message will not show up in final output #} +{% set tracker_middle = deferred %} diff --git a/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.expected.jinja b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.expected.jinja new file mode 100644 index 000000000..02aa14818 --- /dev/null +++ b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.expected.jinja @@ -0,0 +1,15 @@ +-----Pre-First----- + +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true + +-----Post-First----- +-----Pre-Second----- + +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true + +-----Post-Second----- +We aren't deferring tracker base. \ No newline at end of file diff --git a/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.jinja b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.jinja new file mode 100644 index 000000000..b91210414 --- /dev/null +++ b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.expected.jinja @@ -0,0 +1,36 @@ +{# Start Label: ignored_output_from_extends #}{% do %} + + + +Deferring tracker test. +{% set tracker_test = deferred %} + + + + + +Deferring tracker middle. +{% set tracker_middle = deferred %} +{% enddo %}\ +{# End Label: ignored_output_from_extends #}{% set current_path = 'eager/does-not-defer-block-when-only-middle-defers/base.jinja' %} + + +-----Pre-First----- +{% set __temp_meta_current_path_1008935144__,current_path = current_path,'eager/does-not-defer-block-when-only-middle-defers/test.jinja' %} +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +{% set current_path,__temp_meta_current_path_1008935144__ = __temp_meta_current_path_1008935144__,null %} +-----Post-First----- +-----Pre-Second----- +{% set __temp_meta_current_path_245328778__,current_path = current_path,'eager/does-not-defer-block-when-only-middle-defers/middle.jinja' %} +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +{% set current_path,__temp_meta_current_path_245328778__ = __temp_meta_current_path_245328778__,null %} +-----Post-Second----- +We aren't deferring tracker base. \ No newline at end of file diff --git a/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.jinja b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.jinja new file mode 100644 index 000000000..2cd4582cc --- /dev/null +++ b/src/test/resources/eager/does-not-defer-block-when-only-middle-defers/test.jinja @@ -0,0 +1,10 @@ +{% extends '../../eager/does-not-defer-block-when-only-middle-defers/middle.jinja' %} + +{% set tracker_test = '3_test' %} +{% block first %} +tracker_base is '1_base': {{ tracker_base }}? {{ tracker_base == '1_base' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{% endblock %} +Deferring tracker test.{# This message will not show up in final output #} +{% set tracker_test = deferred %} diff --git a/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.expected.jinja b/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.expected.jinja new file mode 100644 index 000000000..44253525f --- /dev/null +++ b/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.expected.jinja @@ -0,0 +1,10 @@ +startab + +startab + + +startab + +startab + +start diff --git a/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.jinja b/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.jinja new file mode 100644 index 000000000..0e1e4a8a2 --- /dev/null +++ b/src/test/resources/eager/does-not-override-import-modification-in-for/test.expected.jinja @@ -0,0 +1,81 @@ +{% set foo = 'start' %}\ +{% for __ignored__ in [0] %} +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016318__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = 'starta' %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016318__.update({'foo': foo,'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar1 = __temp_meta_import_alias_3016318__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +{{ bar1.foo }} +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016319__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = filter:join.filter([foo, 'a'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016319__.update({'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar2 = __temp_meta_import_alias_3016319__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +{{ bar2.foo }} + +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016318__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = filter:join.filter([foo, 'a'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016318__.update({'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar1 = __temp_meta_import_alias_3016318__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +{{ bar1.foo }} +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016319__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = filter:join.filter([foo, 'a'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016319__.update({'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar2 = __temp_meta_import_alias_3016319__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +{{ bar2.foo }} +{% endfor %} +{{ foo }} \ No newline at end of file diff --git a/src/test/resources/eager/does-not-override-import-modification-in-for/test.jinja b/src/test/resources/eager/does-not-override-import-modification-in-for/test.jinja new file mode 100644 index 000000000..e5d54cb53 --- /dev/null +++ b/src/test/resources/eager/does-not-override-import-modification-in-for/test.jinja @@ -0,0 +1,8 @@ +{% set foo = 'start' %} +{% for i in range(2) %} +{% import "../supplements/deferred-modification.jinja" as bar1 %} +{{ bar1.foo }} +{% import "../supplements/deferred-modification.jinja" as bar2 %} +{{ bar2.foo }} +{% endfor %} +{{ foo }} diff --git a/src/test/resources/eager/does-not-reconstruct-extra-times/test.expected.jinja b/src/test/resources/eager/does-not-reconstruct-extra-times/test.expected.jinja new file mode 100644 index 000000000..eddbba409 --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-extra-times/test.expected.jinja @@ -0,0 +1,23 @@ +{% for __ignored__ in [0] %} + +{% set foo = deferred %} +{% endfor %} + + +{% set foo = deferred %} + + +{% for __ignored__ in [0] %} +{% if deferred %} +{{ foo }} +{% set foo = 'second' %} +{% endif %} +{{ foo }} +{% endfor %} +{{ foo }} + + +{% if deferred %} +{% set foo = 'second' %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/does-not-reconstruct-extra-times/test.jinja b/src/test/resources/eager/does-not-reconstruct-extra-times/test.jinja new file mode 100644 index 000000000..29e3a601a --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-extra-times/test.jinja @@ -0,0 +1,25 @@ +{% set foo = 'first' %} +{% for i in range(1) %} +{# this should do nothing because it's in a for loop #} +{% set foo = deferred %} +{% endfor %} + +{# actually defer foo #} +{% set foo = deferred %} + +{# make sure we don't reconstruct foo = 'first' in front of the for block #} +{% for i in range(1) %} +{% if deferred %} +{{ foo }} +{% set foo = 'second' %} +{% endif %} +{{ foo }} +{% endfor %} +{{ foo }} + +{# make sure we don't reconstruct foo = 'first' in front of the if block #} +{% if deferred %} +{% set foo = 'second' %} +{% endif %} +{{ foo }} + diff --git a/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.expected.jinja b/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.expected.jinja new file mode 100644 index 000000000..44ebdcc44 --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.expected.jinja @@ -0,0 +1,6 @@ +{% set my_list = [] %}\ +{% set foo %} +{% do my_list.append(deferred) %}\ +a +{% endset %} +{{ my_list }} \ No newline at end of file diff --git a/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.jinja b/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.jinja new file mode 100644 index 000000000..b1465b18a --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-variable-in-set-in-wrong-scope/test.jinja @@ -0,0 +1,5 @@ +{% set my_list = [] %} +{% set foo %} +{% do my_list.append(deferred) %}a +{% endset %} +{{ my_list }} \ No newline at end of file diff --git a/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.expected.jinja b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.expected.jinja new file mode 100644 index 000000000..f7050f228 --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.expected.jinja @@ -0,0 +1 @@ +['a', 'b', 'c', 'd'] diff --git a/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.jinja b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.jinja new file mode 100644 index 000000000..a403d3d68 --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.expected.jinja @@ -0,0 +1,19 @@ +{% set my_list = ['a'] %}\ +{% if deferred %} +{% set __macro_append_stuff_153654787_temp_variable_0__ %} +{% set __macro_foo_97643642_temp_variable_1__ %} +{% do my_list.append('b') %} +{% endset %}\ +{{ __macro_foo_97643642_temp_variable_1__ }} +{% set __macro_foo_97643642_temp_variable_2__ %} +{% do my_list.append('c') %} +{% endset %}\ +{{ __macro_foo_97643642_temp_variable_2__ }} +{% endset %}\ +{{ __macro_append_stuff_153654787_temp_variable_0__ }} +{% endif %} +{% for __ignored__ in [0] %} +{% do my_list.append('d') %} +{% endfor %} + +{{ my_list }} diff --git a/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.jinja b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.jinja new file mode 100644 index 000000000..c83d5883d --- /dev/null +++ b/src/test/resources/eager/does-not-reconstruct-variable-in-wrong-scope/test.jinja @@ -0,0 +1,17 @@ +{% macro foo(var) %} +{% do my_list.append(var) %} +{% endmacro %} + +{% macro append_stuff() %} +{{ foo('b') }} +{{ foo('c') }} +{% endmacro %} + +{% set my_list = [] %} +{{ foo('a') }} +{% if deferred %} +{{ append_stuff() }} +{% endif %} +{{ foo('d') }} + +{{ my_list }} diff --git a/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.expected.jinja b/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.expected.jinja new file mode 100644 index 000000000..70ac1b49a --- /dev/null +++ b/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.expected.jinja @@ -0,0 +1,2 @@ +{% set foo = deferred %} +{{ foo }} and [1]. diff --git a/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.jinja b/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.jinja new file mode 100644 index 000000000..c148f3e64 --- /dev/null +++ b/src/test/resources/eager/does-not-referential-defer-for-set-vars/test.jinja @@ -0,0 +1,5 @@ +{% set foo = [] %} +{%- set bar = foo %} +{% do foo.append(1) %} +{% set foo = deferred %} +{{ foo }} and {{ bar }}. diff --git a/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.expected.jinja b/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.expected.jinja new file mode 100644 index 000000000..889c365e4 --- /dev/null +++ b/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.expected.jinja @@ -0,0 +1,3 @@ +{% for i in deferred %} +hey +{% endfor %} \ No newline at end of file diff --git a/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.jinja b/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.jinja new file mode 100644 index 000000000..1e55549b4 --- /dev/null +++ b/src/test/resources/eager/does-not-stack-overflow-trying-to-build-hashcode/test.jinja @@ -0,0 +1,12 @@ +{% set l1000_1 = [] %} +{% set l1000_2 = [] %} +{% set l1000_3 = [] %} + +{% do l1000_1.append(l1000_2) %} +{% do l1000_2.append(l1000_3) %} +{% do l1000_3.append(l1000_1) %} + + +{% for i in deferred %} +{{ 'hey' }} +{% endfor %} \ No newline at end of file diff --git a/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.expected.jinja b/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.expected.jinja new file mode 100644 index 000000000..5a91d037a --- /dev/null +++ b/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.expected.jinja @@ -0,0 +1,7 @@ +{% set foo = 1 %}\ +{% if deferred %} +{% set foo = 2 %} +{% else %} +{% set foo = 3 %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.jinja b/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.jinja new file mode 100644 index 000000000..7ed99c3cb --- /dev/null +++ b/src/test/resources/eager/doesnt-affect-parent-from-eager-if/test.jinja @@ -0,0 +1,7 @@ +{% set foo = 1 %} +{% if deferred %} +{% set foo = 2 %} +{% else %} +{% set foo = 3 %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.expected.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.expected.jinja new file mode 100644 index 000000000..818d51cd0 --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.expected.jinja @@ -0,0 +1,9 @@ +{% set foo = [0, 1] %}\ +{% if deferred == true %} +[0, 1] +{% do foo.append(2) %} +{% else %} +[0, 1] +{% do foo.append(3) %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.jinja new file mode 100644 index 000000000..14e153f86 --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-if-tag/test.jinja @@ -0,0 +1,9 @@ +{% set foo = [0] %} +{% if deferred == foo.append(1) %} +{{ foo }} +{% do foo.append(2) %} +{% else %} +{{ foo }} +{% do foo.append(3) %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.expected.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.expected.jinja new file mode 100644 index 000000000..383a93c39 --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.expected.jinja @@ -0,0 +1,10 @@ +{% set my_list = ['a'] %}\ +{% set __macro_foo_97643642_temp_variable_0__ %} +a +{% if deferred %} +{% do my_list.append('b') %}\ +b +{% endif %} +{% endset %}\ +{{ __macro_foo_97643642_temp_variable_0__ }} +{{ my_list }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.jinja new file mode 100644 index 000000000..73d7137e8 --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-macro/test.jinja @@ -0,0 +1,9 @@ +{% set my_list = [] %} +{% macro foo() %} +{% do my_list.append('a') %}a +{% if deferred %} +{% do my_list.append('b') %}b +{% endif %} +{% endmacro %} +{{ foo() }} +{{ my_list }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.expected.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.expected.jinja new file mode 100644 index 000000000..36932e628 --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.expected.jinja @@ -0,0 +1,9 @@ +{% set my_list = ['a'] %}\ +{% set foo %} +a +{% if deferred %} +{% do my_list.append('b') %}\ +b +{% endif %} +{% endset %} +{{ my_list }} diff --git a/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.jinja b/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.jinja new file mode 100644 index 000000000..ec70ff1ca --- /dev/null +++ b/src/test/resources/eager/doesnt-double-append-in-deferred-set/test.jinja @@ -0,0 +1,8 @@ +{% set my_list = [] %} +{% set foo %} +{% do my_list.append('a') %}a +{% if deferred %} +{% do my_list.append('b') %}b +{% endif %} +{% endset %} +{{ my_list }} diff --git a/src/test/resources/eager/doesnt-overwrite-elif/test.expected.jinja b/src/test/resources/eager/doesnt-overwrite-elif/test.expected.jinja new file mode 100644 index 000000000..bf6893d6a --- /dev/null +++ b/src/test/resources/eager/doesnt-overwrite-elif/test.expected.jinja @@ -0,0 +1,6 @@ +{% set foo = [0] %}\ +{% if false %}\ +{% elif deferred && foo.append(1) %}\ +1{% elif deferred && foo.append(2) %}\ +2{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/doesnt-overwrite-elif/test.jinja b/src/test/resources/eager/doesnt-overwrite-elif/test.jinja new file mode 100644 index 000000000..0d241b6c1 --- /dev/null +++ b/src/test/resources/eager/doesnt-overwrite-elif/test.jinja @@ -0,0 +1,3 @@ +{% set foo = [0] %} +{% if foo == [9] %}9{% elif deferred && foo.append(1) %}1{% elif deferred && foo.append(2) %}2{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/eagerly-defers-macro/test.expected.expected.jinja b/src/test/resources/eager/eagerly-defers-macro/test.expected.expected.jinja new file mode 100644 index 000000000..706ad906e --- /dev/null +++ b/src/test/resources/eager/eagerly-defers-macro/test.expected.expected.jinja @@ -0,0 +1,4 @@ +I am foo + + +No more foo diff --git a/src/test/resources/eager/eagerly-defers-macro/test.expected.jinja b/src/test/resources/eager/eagerly-defers-macro/test.expected.jinja new file mode 100644 index 000000000..d8dc2cff7 --- /dev/null +++ b/src/test/resources/eager/eagerly-defers-macro/test.expected.jinja @@ -0,0 +1,12 @@ +{% set __macro_big_guy_1311704000_temp_variable_0__ %} +{% if deferred %}\ +I am foo{% else %}\ +I am bar{% endif %} +{% endset %}\ +{% print __macro_big_guy_1311704000_temp_variable_0__ %} +{% set __macro_big_guy_1311704000_temp_variable_1__ %} +{% if deferred %}\ +No more foo{% else %}\ +I am bar{% endif %} +{% endset %}\ +{% print __macro_big_guy_1311704000_temp_variable_1__ %} diff --git a/src/test/resources/eager/eagerly-defers-macro/test.jinja b/src/test/resources/eager/eagerly-defers-macro/test.jinja new file mode 100644 index 000000000..673f7f619 --- /dev/null +++ b/src/test/resources/eager/eagerly-defers-macro/test.jinja @@ -0,0 +1,16 @@ +{%- macro foo_macro() -%} +{{ foo }} +{%- endmacro -%} +{%- macro bar_macro() -%} +{{ bar }} +{%- endmacro -%} +{% macro big_guy() %} +{% if deferred %} +{%- print foo_macro() -%} +{% else %} +{%- print bar_macro() -%} +{% endif %} +{% endmacro %} +{% print big_guy() %} +{% set foo = 'No more foo' -%} +{% print big_guy() %} diff --git a/src/test/resources/eager/eagerly-defers-set/test.expected.jinja b/src/test/resources/eager/eagerly-defers-set/test.expected.jinja new file mode 100644 index 000000000..9435a4a51 --- /dev/null +++ b/src/test/resources/eager/eagerly-defers-set/test.expected.jinja @@ -0,0 +1,14 @@ +{% set foo = range(0, deferred) %} +{% for item in foo %} +{{ item }} + +Yes bar + +{% if item == 0 %} +At least one: true +{% elif item == 1 %} +Exactly one: true +{% else %} +More than one: true +{% endif %} +{% endfor %} diff --git a/src/test/resources/eager/eagerly-defers-set/test.jinja b/src/test/resources/eager/eagerly-defers-set/test.jinja new file mode 100644 index 000000000..0720d0dbc --- /dev/null +++ b/src/test/resources/eager/eagerly-defers-set/test.jinja @@ -0,0 +1,16 @@ +{% set foo = range(0, deferred) %} +{% for item in foo %} +{{ item }} +{% if bar %} +Yes bar +{% else %} +No bar +{% endif %} +{% if item == 0 %} +At least one: {{ bar }} +{% elif item == 1 %} +Exactly one: {{ bar }} +{% else %} +More than one: {{ bar }} +{% endif %} +{% endfor %} diff --git a/src/test/resources/eager/evaluates-non-eager-set/test.expected.jinja b/src/test/resources/eager/evaluates-non-eager-set/test.expected.jinja new file mode 100644 index 000000000..7c4f02cd9 --- /dev/null +++ b/src/test/resources/eager/evaluates-non-eager-set/test.expected.jinja @@ -0,0 +1,7 @@ +{% for item in [0, 1, 2, 3, 4] + deferred %} +{% if item == 5 %} +It is foo! +{% else %} +It is not foo! +{% endif %} +{% endfor %} diff --git a/src/test/resources/eager/evaluates-non-eager-set/test.jinja b/src/test/resources/eager/evaluates-non-eager-set/test.jinja new file mode 100644 index 000000000..93c3c19b6 --- /dev/null +++ b/src/test/resources/eager/evaluates-non-eager-set/test.jinja @@ -0,0 +1,8 @@ +{% set foo = 5.5|int %} +{% for item in range(0, foo) + deferred %} +{% if item == foo %} +It is foo! +{% else %} +It is not foo! +{% endif %} +{% endfor %} diff --git a/src/test/resources/eager/fails-on-modification-in-aliased-macro/settings.jinja b/src/test/resources/eager/fails-on-modification-in-aliased-macro/settings.jinja new file mode 100644 index 000000000..7c9feac3c --- /dev/null +++ b/src/test/resources/eager/fails-on-modification-in-aliased-macro/settings.jinja @@ -0,0 +1,5 @@ +{% set settings = {} %} + +{% macro load_settings() %} +{% do settings.put('foo', 'bar') %} +{% endmacro %} diff --git a/src/test/resources/eager/fails-on-modification-in-aliased-macro/test.jinja b/src/test/resources/eager/fails-on-modification-in-aliased-macro/test.jinja new file mode 100644 index 000000000..54f2dd54d --- /dev/null +++ b/src/test/resources/eager/fails-on-modification-in-aliased-macro/test.jinja @@ -0,0 +1,6 @@ +{% import './settings.jinja' as shared %} + +{% if deferred %} +{{ shared.load_settings() }} +{% endif %} +{{ shared.settings }} diff --git a/src/test/resources/eager/finds-deferred-words-inside-reconstructed-string/test.expected.jinja b/src/test/resources/eager/finds-deferred-words-inside-reconstructed-string/test.expected.jinja new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/eager/finds-deferred-words-inside-reconstructed-string/test.jinja b/src/test/resources/eager/finds-deferred-words-inside-reconstructed-string/test.jinja new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/eager/fully-defers-filtered-macro/test.expected.expected.jinja b/src/test/resources/eager/fully-defers-filtered-macro/test.expected.expected.jinja new file mode 100644 index 000000000..0bfae827e --- /dev/null +++ b/src/test/resources/eager/fully-defers-filtered-macro/test.expected.expected.jinja @@ -0,0 +1,6 @@ +BAR + A FLASHY RESOLVED. + A flashy resolved. +--- + +A SILLY RESOLVED. \ No newline at end of file diff --git a/src/test/resources/eager/fully-defers-filtered-macro/test.expected.jinja b/src/test/resources/eager/fully-defers-filtered-macro/test.expected.jinja new file mode 100644 index 000000000..4938403bd --- /dev/null +++ b/src/test/resources/eager/fully-defers-filtered-macro/test.expected.jinja @@ -0,0 +1,15 @@ +{% macro flashy(foo) %}\ +{{ filter:upper.filter(foo, ____int3rpr3t3r____) }} + A flashy {{ deferred }}\ +.{% endmacro %}\ +{% set __macro_flashy_1625622909_temp_variable_0__ %}\ +BAR + A flashy {{ deferred }}\ +.{% endset %}\ +{{ flashy(__macro_flashy_1625622909_temp_variable_0__) }} +--- + +{% set __macro_silly_2092874071_temp_variable_0__ %}\ +A silly {{ deferred }}\ +.{% endset %}\ +{{ filter:upper.filter(__macro_silly_2092874071_temp_variable_0__, ____int3rpr3t3r____) }} \ No newline at end of file diff --git a/src/test/resources/eager/fully-defers-filtered-macro/test.jinja b/src/test/resources/eager/fully-defers-filtered-macro/test.jinja new file mode 100644 index 000000000..e2a4904f0 --- /dev/null +++ b/src/test/resources/eager/fully-defers-filtered-macro/test.jinja @@ -0,0 +1,10 @@ +{% macro flashy(foo) -%} + {{ foo|upper }} + A flashy {{ deferred }}. +{%- endmacro %} +{{ flashy(flashy('bar')) }} +--- +{% macro silly() -%} +A silly {{ deferred }}. +{%- endmacro %} +{{ silly()|upper }} diff --git a/src/test/resources/eager/handles-auto-escape/test.expected.jinja b/src/test/resources/eager/handles-auto-escape/test.expected.jinja new file mode 100644 index 000000000..7c2796908 --- /dev/null +++ b/src/test/resources/eager/handles-auto-escape/test.expected.jinja @@ -0,0 +1,8 @@ +1. foo < bar (Only expression nodes get escaped currently) +2. {% autoescape %}\ +{% print deferred %}\ +{% endautoescape %} +3. foo < bar +4. {% autoescape %}\ +{{ deferred }}\ +{% endautoescape %} diff --git a/src/test/resources/eager/handles-auto-escape/test.jinja b/src/test/resources/eager/handles-auto-escape/test.jinja new file mode 100644 index 000000000..c678e76df --- /dev/null +++ b/src/test/resources/eager/handles-auto-escape/test.jinja @@ -0,0 +1,7 @@ +{% autoescape %} +1. {% print myvar %} (Only expression nodes get escaped currently) +2. {% print deferred %} +3. {{ myvar }} +4. {{ deferred }} +{% endautoescape %} + diff --git a/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.expected.jinja b/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.expected.jinja new file mode 100644 index 000000000..1805bb41c --- /dev/null +++ b/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.expected.jinja @@ -0,0 +1 @@ +I AM IRON MAN diff --git a/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.jinja b/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.jinja new file mode 100644 index 000000000..56b32565b --- /dev/null +++ b/src/test/resources/eager/handles-block-set-in-deferred-if/test.expected.jinja @@ -0,0 +1,7 @@ +{% set foo = 'empty' %}\ +{% if deferred %} +{% set foo %}\ +i am iron man{% endset %}\ +{% set foo = 'I AM IRON MAN' %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/handles-block-set-in-deferred-if/test.jinja b/src/test/resources/eager/handles-block-set-in-deferred-if/test.jinja new file mode 100644 index 000000000..3afd63a39 --- /dev/null +++ b/src/test/resources/eager/handles-block-set-in-deferred-if/test.jinja @@ -0,0 +1,7 @@ +{% set foo = 'empty' %} +{% if deferred %} +{% set foo | upper -%} +{{ 'i am iron man' }} +{%- endset %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.expected.jinja b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.expected.jinja new file mode 100644 index 000000000..4df3001da --- /dev/null +++ b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.expected.jinja @@ -0,0 +1,4 @@ +Start loop +i is: 0 +i is: 1 +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.jinja b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.jinja new file mode 100644 index 000000000..c7677739d --- /dev/null +++ b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.expected.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in deferred %}\ + {% if i > 1 %}\ + {% break '' %}\ + {% endif %}\ + i is: {{ i }} +{% endfor %}\ +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-break-in-deferred-for-loop/test.jinja b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.jinja new file mode 100644 index 000000000..8cd047c06 --- /dev/null +++ b/src/test/resources/eager/handles-break-in-deferred-for-loop/test.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in deferred -%} + {% if i > 1 -%} + {% break -%} + {% endif -%} + i is: {{ i }} +{% endfor -%} +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.expected.jinja b/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.expected.jinja new file mode 100644 index 000000000..0d2367dfe --- /dev/null +++ b/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.expected.jinja @@ -0,0 +1,3 @@ +0 + +1 diff --git a/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.jinja b/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.jinja new file mode 100644 index 000000000..b018fdabe --- /dev/null +++ b/src/test/resources/eager/handles-clashing-name-in-macro/test.expected.jinja @@ -0,0 +1,5 @@ +{% macro func(foo) %} +{{ foo }} +{% endmacro %}\ +{{ func(foo=deferred) }} +1 diff --git a/src/test/resources/eager/handles-clashing-name-in-macro/test.jinja b/src/test/resources/eager/handles-clashing-name-in-macro/test.jinja new file mode 100644 index 000000000..1fb1da91a --- /dev/null +++ b/src/test/resources/eager/handles-clashing-name-in-macro/test.jinja @@ -0,0 +1,6 @@ +{% macro func(foo) %} +{{ foo }} +{% endmacro %} +{% set foo = 1 %} +{{ func(foo=deferred) }} +{{ foo }} diff --git a/src/test/resources/eager/handles-complex-raw/test.expected.jinja b/src/test/resources/eager/handles-complex-raw/test.expected.jinja new file mode 100644 index 000000000..5b922a92f --- /dev/null +++ b/src/test/resources/eager/handles-complex-raw/test.expected.jinja @@ -0,0 +1,8 @@ +1 +{% raw %}\ +{{ 2 }}\ +{% endraw %} +3 +{% raw %}\ +{{ 4 }}\ +{% endraw %} diff --git a/src/test/resources/eager/handles-complex-raw/test.jinja b/src/test/resources/eager/handles-complex-raw/test.jinja new file mode 100644 index 000000000..ffa63591a --- /dev/null +++ b/src/test/resources/eager/handles-complex-raw/test.jinja @@ -0,0 +1,10 @@ +{%- macro raw_exp(var) -%} +{{ '{% raw %}}} ' ~ var ~ ' {{{% endraw %}' }} +{%- endmacro -%} +{%- macro raw_print(var) -%} +{% print '}} ' ~ var ~ ' {{' %} +{%- endmacro -%} +{{ raw_exp(1)|reverse }} +{% print raw_print(2)|reverse %} +{{ raw_print(3)|reverse }} +{% print raw_exp(4)|reverse %} diff --git a/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.expected.jinja b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.expected.jinja new file mode 100644 index 000000000..a134c702a --- /dev/null +++ b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.expected.jinja @@ -0,0 +1,5 @@ +Start loop +i is: 1 +i is: 3 +i is: 5 +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.jinja b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.jinja new file mode 100644 index 000000000..c2ba0a12c --- /dev/null +++ b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.expected.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in deferred %}\ + {% if i % 2 == 0 %}\ + {% continue '' %}\ + {% endif %}\ + i is: {{ i }} +{% endfor %}\ +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.jinja b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.jinja new file mode 100644 index 000000000..710517afa --- /dev/null +++ b/src/test/resources/eager/handles-continue-in-deferred-for-loop/test.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in deferred -%} + {% if i % 2 == 0 -%} + {% continue -%} + {% endif -%} + i is: {{ i }} +{% endfor -%} +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.expected.jinja b/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.expected.jinja new file mode 100644 index 000000000..871147b6e --- /dev/null +++ b/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.expected.jinja @@ -0,0 +1,8 @@ +1 +1 +2 +2 +3 +3 +1 +1 diff --git a/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.jinja b/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.jinja new file mode 100644 index 000000000..f5289ebf5 --- /dev/null +++ b/src/test/resources/eager/handles-cycle-in-deferred-for/test.expected.jinja @@ -0,0 +1,4 @@ +{% for item in deferred %} +{% cycle '1','2','3' %} +{% cycle '1','2','3' %}\ +{% endfor %} diff --git a/src/test/resources/eager/handles-cycle-in-deferred-for/test.jinja b/src/test/resources/eager/handles-cycle-in-deferred-for/test.jinja new file mode 100644 index 000000000..9cbe73032 --- /dev/null +++ b/src/test/resources/eager/handles-cycle-in-deferred-for/test.jinja @@ -0,0 +1,8 @@ +{% set foo = ['1','2','3'] %} +{% set one = '1' %} +{% set two = '2' %} +{% set three = '3' %} +{%- for item in deferred %} +{% cycle foo %} +{% cycle one,two,three %} +{%- endfor -%} diff --git a/src/test/resources/eager/handles-cycle-with-quote/test.expected.jinja b/src/test/resources/eager/handles-cycle-with-quote/test.expected.jinja new file mode 100644 index 000000000..08658abb8 --- /dev/null +++ b/src/test/resources/eager/handles-cycle-with-quote/test.expected.jinja @@ -0,0 +1,2 @@ +
    +
    diff --git a/src/test/resources/eager/handles-cycle-with-quote/test.jinja b/src/test/resources/eager/handles-cycle-with-quote/test.jinja new file mode 100644 index 000000000..6ade093fd --- /dev/null +++ b/src/test/resources/eager/handles-cycle-with-quote/test.jinja @@ -0,0 +1,3 @@ +{% for i in range(2) -%} +{% cycle "
    ",'
    ' %} +{% endfor %} diff --git a/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.expected.jinja new file mode 100644 index 000000000..4df3001da --- /dev/null +++ b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.expected.jinja @@ -0,0 +1,4 @@ +Start loop +i is: 0 +i is: 1 +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.jinja b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.jinja new file mode 100644 index 000000000..b37cf88b3 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.expected.jinja @@ -0,0 +1,24 @@ +Start loop +{% for __ignored__ in [0] %}\ +{% if 0 > deferred %}\ + {% break '' %}\ + {% endif %}\ +i is: 0 +{% if 1 > deferred %}\ + {% break '' %}\ + {% endif %}\ +i is: 1 +{% if 2 > deferred %}\ + {% break '' %}\ + {% endif %}\ +i is: 2 +{% if 3 > deferred %}\ + {% break '' %}\ + {% endif %}\ +i is: 3 +{% if 4 > deferred %}\ + {% break '' %}\ + {% endif %}\ + i is: 4 +{% endfor %}\ +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-break-in-for-loop/test.jinja b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.jinja new file mode 100644 index 000000000..65718591e --- /dev/null +++ b/src/test/resources/eager/handles-deferred-break-in-for-loop/test.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in range(5) -%} + {% if i > deferred -%} + {% break -%} + {% endif -%} + i is: {{ i }} +{% endfor -%} +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.expected.jinja new file mode 100644 index 000000000..d1616c7c9 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.expected.jinja @@ -0,0 +1,4 @@ +Start loop +i is: 1 +i is: 3 +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.jinja b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.jinja new file mode 100644 index 000000000..52f85138e --- /dev/null +++ b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.expected.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in [0, 1, 2, 3, 4] %}\ + {% if i % deferred == 0 %}\ + {% continue '' %}\ + {% endif %}\ + i is: {{ i }} +{% endfor %}\ +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.jinja b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.jinja new file mode 100644 index 000000000..07173400f --- /dev/null +++ b/src/test/resources/eager/handles-deferred-continue-in-for-loop/test.jinja @@ -0,0 +1,8 @@ +Start loop +{% for i in range(5) -%} + {% if i % deferred == 0 -%} + {% continue -%} + {% endif -%} + i is: {{ i }} +{% endfor -%} +End loop \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-cycle-as/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-cycle-as/test.expected.expected.jinja new file mode 100644 index 000000000..84520bf29 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-cycle-as/test.expected.expected.jinja @@ -0,0 +1,5 @@ +1 + +hello + +3 diff --git a/src/test/resources/eager/handles-deferred-cycle-as/test.expected.jinja b/src/test/resources/eager/handles-deferred-cycle-as/test.expected.jinja new file mode 100644 index 000000000..6c84f4a4f --- /dev/null +++ b/src/test/resources/eager/handles-deferred-cycle-as/test.expected.jinja @@ -0,0 +1,20 @@ +{% for __ignored__ in [0] %} +{% set c = [1, deferred] %} +{% if exptest:iterable.evaluate(c, null) %}\ +{{ c[0 % filter:length.filter(c, ____int3rpr3t3r____)] }}\ +{% else %}\ +{{ c }}\ +{% endif %} +{% set c = [2, deferred] %} +{% if exptest:iterable.evaluate(c, null) %}\ +{{ c[1 % filter:length.filter(c, ____int3rpr3t3r____)] }}\ +{% else %}\ +{{ c }}\ +{% endif %} +{% set c = [3, deferred] %} +{% if exptest:iterable.evaluate(c, null) %}\ +{{ c[2 % filter:length.filter(c, ____int3rpr3t3r____)] }}\ +{% else %}\ +{{ c }}\ +{% endif %}\ +{% endfor %} diff --git a/src/test/resources/eager/handles-deferred-cycle-as/test.jinja b/src/test/resources/eager/handles-deferred-cycle-as/test.jinja new file mode 100644 index 000000000..f1a1f5829 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-cycle-as/test.jinja @@ -0,0 +1,5 @@ +{% set foo = [1,2,3] %} +{%- for item in foo %} +{% cycle item,deferred as c %} +{% cycle c %} +{%- endfor -%} diff --git a/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.expected.jinja new file mode 100644 index 000000000..8523d864d --- /dev/null +++ b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.expected.jinja @@ -0,0 +1,5 @@ +RESOLVED{"A":"A"} + + + +RESOLVED{"B":"B"} diff --git a/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.jinja b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.jinja new file mode 100644 index 000000000..54d9fc69a --- /dev/null +++ b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.expected.jinja @@ -0,0 +1,16 @@ +{% set __macro_getData_357124436_temp_variable_0__ %} + + +{% for __ignored__ in [0] %} +{% set __macro_doIt_1327224118_temp_variable_0__ %} +{{ deferred ~ '{\"a\":\"a\"}' }} +{% endset %}\ +{{ filter:upper.filter(__macro_doIt_1327224118_temp_variable_0__, ____int3rpr3t3r____) }} + +{% set __macro_doIt_1327224118_temp_variable_1__ %} +{{ deferred ~ '{\"b\":\"b\"}' }} +{% endset %}\ +{{ filter:upper.filter(__macro_doIt_1327224118_temp_variable_1__, ____int3rpr3t3r____) }} +{% endfor %} +{% endset %}\ +{{ filter:upper.filter(__macro_getData_357124436_temp_variable_0__, ____int3rpr3t3r____) }} diff --git a/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.jinja b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.jinja new file mode 100644 index 000000000..c740dca4e --- /dev/null +++ b/src/test/resources/eager/handles-deferred-for-loop-var-from-macro/test.jinja @@ -0,0 +1,11 @@ +{% macro getData() %} +{% macro doIt(val) %} +{{ deferred ~ val|tojson }} +{% endmacro %} + +{% for val in [{'a': 'a'}, {'b': 'b'}] %} +{{ doIt(val)|upper }} +{% endfor %} +{% endmacro %} + +{{ getData()|upper }} diff --git a/src/test/resources/eager/handles-deferred-from-import-as/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-from-import-as/test.expected.expected.jinja new file mode 100644 index 000000000..fae43d64e --- /dev/null +++ b/src/test/resources/eager/handles-deferred-from-import-as/test.expected.expected.jinja @@ -0,0 +1,2 @@ +from_foo: Hello 8 +from_bar: 27 diff --git a/src/test/resources/eager/handles-deferred-from-import-as/test.expected.jinja b/src/test/resources/eager/handles-deferred-from-import-as/test.expected.jinja new file mode 100644 index 000000000..f4b6093cd --- /dev/null +++ b/src/test/resources/eager/handles-deferred-from-import-as/test.expected.jinja @@ -0,0 +1,12 @@ +{% set myname = deferred + 7 %}\ +{% do %} +{% set bar = myname + 19 %} +{% for __ignored__ in [0] %}\ +Hello {{ myname }}\ +{% endfor %} +{% set from_bar = bar %}\ +{% enddo %}\ +from_foo: {% for __ignored__ in [0] %}\ +Hello {{ myname }}\ +{% endfor %} +from_bar: {{ from_bar }} diff --git a/src/test/resources/eager/handles-deferred-from-import-as/test.jinja b/src/test/resources/eager/handles-deferred-from-import-as/test.jinja new file mode 100644 index 000000000..5716196eb --- /dev/null +++ b/src/test/resources/eager/handles-deferred-from-import-as/test.jinja @@ -0,0 +1,4 @@ +{%- set myname = deferred + (3 + 4) -%} +{%- from "../supplements/macro-and-set.jinja" import foo as from_foo, bar as from_bar -%} +from_foo: {{ from_foo() }} +from_bar: {{ from_bar }} diff --git a/src/test/resources/eager/handles-deferred-import-vars/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-import-vars/test.expected.expected.jinja new file mode 100644 index 000000000..bf2c40c40 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-import-vars/test.expected.expected.jinja @@ -0,0 +1,5 @@ +foo: Hello 4 +bar: 23 +--- +simple.foo: Hello 8 +simple.bar: 27 diff --git a/src/test/resources/eager/handles-deferred-import-vars/test.expected.jinja b/src/test/resources/eager/handles-deferred-import-vars/test.expected.jinja new file mode 100644 index 000000000..a04803b11 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-import-vars/test.expected.jinja @@ -0,0 +1,34 @@ +{% set myname = deferred + 3 %}\ +{% do %} +{% set bar = myname + 19 %} +{% for __ignored__ in [0] %}\ +Hello {{ myname }}\ +{% endfor %} +{% enddo %}\ +foo: {% for __ignored__ in [0] %}\ +Hello {{ myname }}\ +{% endfor %} +bar: {{ bar }} +--- +{% set myname = deferred + 7 %}\ +{% do %}\ +{% set __temp_meta_current_path_822093108__,current_path = current_path,'eager/supplements/macro-and-set.jinja' %}\ +{% set __temp_meta_import_alias_902286926__ = {} %}\ +{% for __ignored__ in [0] %} +{% set bar = myname + 19 %}\ +{% do __temp_meta_import_alias_902286926__.update({'bar': bar}) %} +{% for __ignored__ in [0] %}\ +Hello {{ myname }}\ +{% endfor %} +{% do __temp_meta_import_alias_902286926__.update({'import_resource_path': 'eager/supplements/macro-and-set.jinja'}) %}\ +{% endfor %}\ +{% set simple = __temp_meta_import_alias_902286926__ %}\ +{% set current_path,__temp_meta_current_path_822093108__ = __temp_meta_current_path_822093108__,null %}\ +{% enddo %}\ +simple.foo: {% set deferred_import_resource_path = 'eager/supplements/macro-and-set.jinja' %}\ +{% macro simple.foo() %}\ +Hello {{ myname }}\ +{% endmacro %}\ +{% set deferred_import_resource_path = null %}\ +{{ simple.foo() }} +simple.bar: {{ simple.bar }} diff --git a/src/test/resources/eager/handles-deferred-import-vars/test.jinja b/src/test/resources/eager/handles-deferred-import-vars/test.jinja new file mode 100644 index 000000000..303c076aa --- /dev/null +++ b/src/test/resources/eager/handles-deferred-import-vars/test.jinja @@ -0,0 +1,9 @@ +{%- set myname = deferred + (1 + 2) -%} +{%- from "../supplements/macro-and-set.jinja" import foo, bar -%} +foo: {{ foo() }} +bar: {{ bar }} +--- +{% set myname = deferred + (3 + 4) -%} +{%- import "../supplements/macro-and-set.jinja" as simple -%} +simple.foo: {{ simple.foo() }} +simple.bar: {{ simple.bar }} diff --git a/src/test/resources/eager/handles-deferred-in-cycle/test.expected.jinja b/src/test/resources/eager/handles-deferred-in-cycle/test.expected.jinja new file mode 100644 index 000000000..330bce2df --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-cycle/test.expected.jinja @@ -0,0 +1,3 @@ +1 +{{ deferred }} +3 diff --git a/src/test/resources/eager/handles-deferred-in-cycle/test.jinja b/src/test/resources/eager/handles-deferred-in-cycle/test.jinja new file mode 100644 index 000000000..81fdec4bd --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-cycle/test.jinja @@ -0,0 +1,4 @@ +{%- set foo = [1,2,3] -%} +{%- for item in foo %} +{% cycle item,deferred %} +{%- endfor -%} diff --git a/src/test/resources/eager/handles-deferred-in-ifchanged/test.expected.jinja b/src/test/resources/eager/handles-deferred-in-ifchanged/test.expected.jinja new file mode 100644 index 000000000..c7fc38922 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-ifchanged/test.expected.jinja @@ -0,0 +1,5 @@ +{% for __ignored__ in [0] %}\ +{{ deferred[1] }}\ +{{ deferred[2] }}\ +{{ deferred[1] }}\ +{% endfor %} diff --git a/src/test/resources/eager/handles-deferred-in-ifchanged/test.jinja b/src/test/resources/eager/handles-deferred-in-ifchanged/test.jinja new file mode 100644 index 000000000..c8179a3af --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-ifchanged/test.jinja @@ -0,0 +1,6 @@ +{% set foo = [1, 1, 2, 1] %} +{%- for item in foo -%} +{%- ifchanged item- %} +{{ deferred[item] }} +{%- endifchanged -%} +{% endfor%} diff --git a/src/test/resources/eager/handles-deferred-in-namespace/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-in-namespace/test.expected.expected.jinja new file mode 100644 index 000000000..f609e84c3 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-namespace/test.expected.expected.jinja @@ -0,0 +1,2 @@ +true +resolved diff --git a/src/test/resources/eager/handles-deferred-in-namespace/test.expected.jinja b/src/test/resources/eager/handles-deferred-in-namespace/test.expected.jinja new file mode 100644 index 000000000..948dd0bc5 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-namespace/test.expected.jinja @@ -0,0 +1,8 @@ +{% set ns2 = namespace({} ) %}\ +{% do ns2.update({'a': deferred}) %} +{% set ns1 = namespace({'a': false} ) %}\ +{% if deferred %} + {% set ns1.a = true %} +{% endif %} +{{ ns1.a }} +{{ ns2.a }} diff --git a/src/test/resources/eager/handles-deferred-in-namespace/test.jinja b/src/test/resources/eager/handles-deferred-in-namespace/test.jinja new file mode 100644 index 000000000..441693c34 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-in-namespace/test.jinja @@ -0,0 +1,8 @@ +{% set ns1 = namespace(a=false) %} +{% set ns2 = namespace() %} +{% do ns2.update({'a': deferred}) %} +{% if deferred %} + {% set ns1.a = true %} +{% endif %} +{{ ns1.a }} +{{ ns2.a }} diff --git a/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.expected.jinja new file mode 100644 index 000000000..06d144d5b --- /dev/null +++ b/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.expected.jinja @@ -0,0 +1,2 @@ +['a', 'b', 'c'] +['a', 'b', 'c', 'd'] diff --git a/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.jinja b/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.jinja new file mode 100644 index 000000000..5933433f9 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-modification-in-caller/test.expected.jinja @@ -0,0 +1,14 @@ +{% set my_list = ['a', 'b'] %}\ +{% set __macro_callerino_729568755_temp_variable_0__ %}\ +{% set __macro_caller_172086791_temp_variable_0__ %}\ +{% do my_list.append(deferred) %}\ +{{ my_list }}\ +{% endset %}\ +{{ __macro_caller_172086791_temp_variable_0__ }}\ +{% do my_list.append('d') %}\ +{% endset %}\ +{% call __macro_callerino_729568755_temp_variable_0__ %}\ +{% do my_list.append(deferred) %}\ +{{ my_list }}\ +{% endcall %} +{{ my_list }} diff --git a/src/test/resources/eager/handles-deferred-modification-in-caller/test.jinja b/src/test/resources/eager/handles-deferred-modification-in-caller/test.jinja new file mode 100644 index 000000000..c0ae33a3c --- /dev/null +++ b/src/test/resources/eager/handles-deferred-modification-in-caller/test.jinja @@ -0,0 +1,11 @@ +{% macro callerino() -%} + {% do my_list.append('b') -%} + {{ caller() }} + {%- do my_list.append('d') -%} +{%- endmacro %} +{% set my_list = ['a'] %} +{% call callerino() -%} + {%- do my_list.append(deferred) -%} + {{ my_list }} +{%- endcall %} +{{ my_list }} diff --git a/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/base.jinja b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/base.jinja new file mode 100644 index 000000000..36cc1947c --- /dev/null +++ b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/base.jinja @@ -0,0 +1,27 @@ +{% set tracker_base = '1_base' %} + +tracker_base is '1_base': {{ tracker_base }}? {{ tracker_base == '1_base' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +-----Pre-First----- +{% block first -%} +tracker_base is 'resolved': {{ tracker_base }}? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{%- endblock %} +-----Post-First----- +tracker_base is '1_base': {{ tracker_base }}? {{ tracker_base == '1_base' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +-----Pre-Second----- +{% block second -%} +tracker_base is 'resolved': {{ tracker_base }}? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{%- endblock %} +-----Post-Second----- +Deferring tracker base.{# This message WILL show up in final output #} +{% set tracker_base = deferred %} +tracker_base is 'resolved': {{ tracker_base }}? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} diff --git a/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/middle.jinja b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/middle.jinja new file mode 100644 index 000000000..c4f5dcbde --- /dev/null +++ b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/middle.jinja @@ -0,0 +1,13 @@ +{% extends '../../eager/handles-deferred-used-in-multiple-block-levels/base.jinja' %} +{% set tracker_middle = '2_middle' %} +{% block first %} +I WON'T SHOW UP +{% endblock %} + +{% block second %} +tracker_base is 'resolved': {{ tracker_base }}? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{% endblock %} +Deferring tracker middle.{# This message will not show up in final output #} +{% set tracker_middle = deferred %} diff --git a/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.expected.jinja b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.expected.jinja new file mode 100644 index 000000000..0c6b1adf8 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.expected.jinja @@ -0,0 +1,25 @@ +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true +-----Pre-First----- + +tracker_base is 'resolved': resolved? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true + +-----Post-First----- +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true +-----Pre-Second----- + +tracker_base is 'resolved': resolved? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true + +-----Post-Second----- +Deferring tracker base. + +tracker_base is 'resolved': resolved? true +tracker_middle is 'resolved': resolved? true +tracker_test is 'resolved': resolved? true \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.jinja b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.jinja new file mode 100644 index 000000000..2e70222aa --- /dev/null +++ b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.expected.jinja @@ -0,0 +1,58 @@ +{# Start Label: ignored_output_from_extends #}{% do %} + + + +Deferring tracker test. +{% set tracker_test = deferred %} + + + + + +Deferring tracker middle. +{% set tracker_middle = deferred %} +{% enddo %}\ +{# End Label: ignored_output_from_extends #}{% set current_path = 'eager/handles-deferred-used-in-multiple-block-levels/base.jinja' %} + +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +-----Pre-First----- +{% block first %}\ +{% set __temp_meta_current_path_1057627035__,current_path = current_path,'eager/handles-deferred-used-in-multiple-block-levels/test.jinja' %} +tracker_base is 'resolved': {{ tracker_base }}\ +? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +{% set current_path,__temp_meta_current_path_1057627035__ = __temp_meta_current_path_1057627035__,null %}\ +{% endblock first %} +-----Post-First----- +tracker_base is '1_base': 1_base? true +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +-----Pre-Second----- +{% block second %}\ +{% set __temp_meta_current_path_697061783__,current_path = current_path,'eager/handles-deferred-used-in-multiple-block-levels/middle.jinja' %} +tracker_base is 'resolved': {{ tracker_base }}\ +? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} +{% set current_path,__temp_meta_current_path_697061783__ = __temp_meta_current_path_697061783__,null %}\ +{% endblock second %} +-----Post-Second----- +Deferring tracker base. +{% set tracker_base = deferred %} +tracker_base is 'resolved': {{ tracker_base }}\ +? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}\ +? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}\ +? {{ tracker_test == 'resolved' }} \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.jinja b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.jinja new file mode 100644 index 000000000..94aecd88c --- /dev/null +++ b/src/test/resources/eager/handles-deferred-used-in-multiple-block-levels/test.jinja @@ -0,0 +1,10 @@ +{% extends '../../eager/handles-deferred-used-in-multiple-block-levels/middle.jinja' %} + +{% set tracker_test = '3_test' %} +{% block first %} +tracker_base is 'resolved': {{ tracker_base }}? {{ tracker_base == 'resolved' }} +tracker_middle is 'resolved': {{ tracker_middle }}? {{ tracker_middle == 'resolved' }} +tracker_test is 'resolved': {{ tracker_test }}? {{ tracker_test == 'resolved' }} +{% endblock %} +Deferring tracker test.{# This message will not show up in final output #} +{% set tracker_test = deferred %} diff --git a/src/test/resources/eager/handles-deferred-value-in-render-filter/test.expected.jinja b/src/test/resources/eager/handles-deferred-value-in-render-filter/test.expected.jinja new file mode 100644 index 000000000..21a51dcc1 --- /dev/null +++ b/src/test/resources/eager/handles-deferred-value-in-render-filter/test.expected.jinja @@ -0,0 +1,9 @@ +{% set __render_524436216_temp_variable__ %}\ +Hi {{ filter:escape.filter(deferred, ____int3rpr3t3r____) }}\ +{% endset %}\ +{{ filter:escape_jinjava.filter(__render_524436216_temp_variable__, ____int3rpr3t3r____) }} + +{% set __render_524436216_temp_variable__ %}\ +Hi {{ filter:escape.filter(deferred, ____int3rpr3t3r____) }}\ +{% endset %}\ +{{ __render_524436216_temp_variable__ }} \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferred-value-in-render-filter/test.jinja b/src/test/resources/eager/handles-deferred-value-in-render-filter/test.jinja new file mode 100644 index 000000000..25c5adf9d --- /dev/null +++ b/src/test/resources/eager/handles-deferred-value-in-render-filter/test.jinja @@ -0,0 +1,4 @@ +{% set foo = "Hi {{ deferred|escape }}" %} +{{ foo|render|escape_jinjava }} + +{{ foo|render }} \ No newline at end of file diff --git a/src/test/resources/eager/handles-deferring-loop-variable/test.expected.jinja b/src/test/resources/eager/handles-deferring-loop-variable/test.expected.jinja new file mode 100644 index 000000000..d27a1c16c --- /dev/null +++ b/src/test/resources/eager/handles-deferring-loop-variable/test.expected.jinja @@ -0,0 +1,17 @@ +{% for __ignored__ in [0] %} +{% if deferred && true %}\ +first time! +{% endif %} +1 + +{% if deferred && false %}\ +first time! +{% endif %} +2 +{% endfor %} +{% for i in [0, 1] %} +{% if deferred && loop.isLast() %}\ +last time! +{% endif %} +{{ loop.index }} +{% endfor %} diff --git a/src/test/resources/eager/handles-deferring-loop-variable/test.jinja b/src/test/resources/eager/handles-deferring-loop-variable/test.jinja new file mode 100644 index 000000000..95ed0f25c --- /dev/null +++ b/src/test/resources/eager/handles-deferring-loop-variable/test.jinja @@ -0,0 +1,13 @@ +{% set foo = range(2) %} +{% for i in foo %} +{% if deferred && loop.index == 1 -%} +first time! +{% endif %} +{{ loop.index }} +{% endfor %} +{% for i in foo %} +{% if deferred && loop.isLast() -%} +last time! +{% endif %} +{{ loop.index }} +{% endfor %} diff --git a/src/test/resources/eager/handles-double-import-modification/test.expected.expected.jinja b/src/test/resources/eager/handles-double-import-modification/test.expected.expected.jinja new file mode 100644 index 000000000..9dd34e37e --- /dev/null +++ b/src/test/resources/eager/handles-double-import-modification/test.expected.expected.jinja @@ -0,0 +1,5 @@ +--- + +--- +b +b diff --git a/src/test/resources/eager/handles-double-import-modification/test.expected.jinja b/src/test/resources/eager/handles-double-import-modification/test.expected.jinja new file mode 100644 index 000000000..66acdde36 --- /dev/null +++ b/src/test/resources/eager/handles-double-import-modification/test.expected.jinja @@ -0,0 +1,40 @@ +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016318__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = 'a' %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016318__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016318__.update({'foo': foo,'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar1 = __temp_meta_import_alias_3016318__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +--- +{% do %}\ +{% set __temp_meta_current_path_461135149__,current_path = current_path,'eager/supplements/deferred-modification.jinja' %}\ +{% set __temp_meta_import_alias_3016319__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} + +{% set foo = 'a' %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} + +{% endif %} + +{% set foo = filter:join.filter([foo, 'b'], ____int3rpr3t3r____, '') %}\ +{% do __temp_meta_import_alias_3016319__.update({'foo': foo}) %} +{% do __temp_meta_import_alias_3016319__.update({'foo': foo,'import_resource_path': 'eager/supplements/deferred-modification.jinja'}) %}\ +{% endfor %}\ +{% set bar2 = __temp_meta_import_alias_3016319__ %}\ +{% set current_path,__temp_meta_current_path_461135149__ = __temp_meta_current_path_461135149__,null %}\ +{% enddo %} +--- +{{ bar1.foo }} +{{ bar2.foo }} diff --git a/src/test/resources/eager/handles-double-import-modification/test.jinja b/src/test/resources/eager/handles-double-import-modification/test.jinja new file mode 100644 index 000000000..ba3e60e62 --- /dev/null +++ b/src/test/resources/eager/handles-double-import-modification/test.jinja @@ -0,0 +1,6 @@ +{% import "../supplements/deferred-modification.jinja" as bar1 %} +--- +{% import "../supplements/deferred-modification.jinja" as bar2 %} +--- +{{ bar1.foo }} +{{ bar2.foo }} diff --git a/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.expected.jinja b/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.expected.jinja new file mode 100644 index 000000000..99f473cf6 --- /dev/null +++ b/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.expected.jinja @@ -0,0 +1,16 @@ +{% set the_list = [] %}\ +{% set __macro_appender_2138849093_temp_variable_0__ %}\ +{% set some_list = the_list %}\ +{% if deferred %} +{% do some_list.append(deferred) %} +{% endif %}\ +{% endset %}\ +{{ __macro_appender_2138849093_temp_variable_0__ }} +{{ the_list }} + + +{% set foo = [1] %}\ +{% do foo.append(deferred) %} +{% do foo.append(2) %} +{% set bar = foo %}\ +{{ foo ~ 'and' ~ bar }} diff --git a/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.jinja b/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.jinja new file mode 100644 index 000000000..d42b711dd --- /dev/null +++ b/src/test/resources/eager/handles-duplicate-variable-reference-modification/test.jinja @@ -0,0 +1,15 @@ +{% set the_list = [] %} +{% macro appender(some_list) -%} +{% if deferred %} +{% do some_list.append(deferred) %} +{% endif %} +{%- endmacro %} +{{ appender(the_list) }} +{{ the_list }} + +{% set foo = [] -%} +{%- set bar = foo -%} +{% do foo.append(1) %} +{% do foo.append(deferred) %} +{% do foo.append(2) %} +{{ foo ~ 'and' ~ bar }} diff --git a/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.expected.jinja b/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.expected.jinja new file mode 100644 index 000000000..92a3012b1 --- /dev/null +++ b/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.expected.jinja @@ -0,0 +1,7 @@ +{% set foo = ['a', 1] %}\ +{% set bar = foo %}\ +{% if deferred %} +{% do bar.append(2) %} +{% endif %} +{% do bar.append(3) %} +{{ foo ~ 'and' ~ bar }} diff --git a/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.jinja b/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.jinja new file mode 100644 index 000000000..ecf1af7be --- /dev/null +++ b/src/test/resources/eager/handles-duplicate-variable-reference-speculative-modification/test.jinja @@ -0,0 +1,8 @@ +{% set foo = ['a'] -%} +{%- set bar = foo -%} +{% do bar.append(1) %} +{% if deferred %} +{% do bar.append(2) %} +{% endif %} +{% do bar.append(3) %} +{{ foo ~ 'and' ~ bar }} diff --git a/src/test/resources/eager/handles-eager-print-and-do/test.expected.jinja b/src/test/resources/eager/handles-eager-print-and-do/test.expected.jinja new file mode 100644 index 000000000..6b980078f --- /dev/null +++ b/src/test/resources/eager/handles-eager-print-and-do/test.expected.jinja @@ -0,0 +1,5 @@ +[] +[1,2] +{% print deferred %} +{% do deferred.append(1) %} +{% print deferred.append(2) %} diff --git a/src/test/resources/eager/handles-eager-print-and-do/test.jinja b/src/test/resources/eager/handles-eager-print-and-do/test.jinja new file mode 100644 index 000000000..fb9d553a0 --- /dev/null +++ b/src/test/resources/eager/handles-eager-print-and-do/test.jinja @@ -0,0 +1,7 @@ +{% set foo = [] %} +{% print foo %} +{% do foo.append(bar) %} +{% print foo.append(bar + 1) %} +{% print deferred %} +{% do deferred.append(bar) %} +{% print deferred.append(bar + 1) %} diff --git a/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.expected.jinja b/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.expected.jinja new file mode 100644 index 000000000..b32d28d29 --- /dev/null +++ b/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.expected.jinja @@ -0,0 +1,8 @@ +C: ['a', 'b', 'c']. +B: ['a', 'b', 'c', 'B']. +A: ['a', 'b', 'c', 'B', 'A']. +--- + +C: ['a', 'b', 'c']. +B: ['a', 'b', 'c', 'B']. +A: ['a', 'b', 'c', 'B', 'A']. diff --git a/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.jinja b/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.jinja new file mode 100644 index 000000000..bbb06de9d --- /dev/null +++ b/src/test/resources/eager/handles-higher-scope-reference-modification/test.expected.jinja @@ -0,0 +1,35 @@ +{% set a_list = ['a'] %}\ +{% set __macro_b_125206_temp_variable_0__ %}\ +{% set b_list = a_list %}\ +{% do b_list.append(deferred ? 'b' : '') %} +{% macro c(c_list) %}\ +{% do c_list.append(deferred ? 'c' : '') %} +C: {{ c_list }}\ +.{% endmacro %}\ +{{ c(b_list) }}\ +{% do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}\ +.{% endset %}\ +{{ __macro_b_125206_temp_variable_0__ }}\ +{% do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}\ +. +--- +{% set a_list = ['a', 'b'] %}\ +{% for __ignored__ in [0] %}\ +{% set b_list = a_list %}\ +{% for __ignored__ in [0] %}\ +{% set c_list = a_list %}\ +{% if !deferred %}\ +{% do c_list.append(deferred ? 'c' : '') %}\ +{% else %}\ +{% do c_list.append(deferred ? 'c' : '') %}\ +{% endif %} +C: {{ c_list }}\ +.{% endfor %}\ +{% do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}\ +.{% endfor %}\ +{% do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}\ +. diff --git a/src/test/resources/eager/handles-higher-scope-reference-modification/test.jinja b/src/test/resources/eager/handles-higher-scope-reference-modification/test.jinja new file mode 100644 index 000000000..bb4ef7645 --- /dev/null +++ b/src/test/resources/eager/handles-higher-scope-reference-modification/test.jinja @@ -0,0 +1,35 @@ +{% set a_list = [] %} +{%- do a_list.append('a') %} +{%- macro c(c_list) %} +{%- do c_list.append(deferred ? 'c' : '') %} +C: {{ c_list }}. +{%- endmacro %} +{%- macro b(b_list) %} +{%- do b_list.append(deferred ? 'b' : '') %} +{{ c(b_list) }} +{%- do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}. +{%- endmacro %} +{{ b(a_list) }} +{%- do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}. +--- +{% set a_list = [] %} +{%- do a_list.append('a') %} +{%- for i in range(1) %} +{%- set b_list = a_list %} +{%- do b_list.append('b') %} +{%- for j in range(1) %} +{%- set c_list = b_list %} +{%- if !deferred %} +{%- do c_list.append(deferred ? 'c' : '') %} +{%- else %} +{%- do c_list.append(deferred ? 'c' : '') %} +{%- endif %} +C: {{ c_list }}. +{%- endfor %} +{%- do b_list.append(deferred ? 'B' : '') %} +B: {{ b_list }}. +{%- endfor %} +{%- do a_list.append(deferred ? 'A' : '') %} +A: {{ a_list }}. diff --git a/src/test/resources/eager/handles-import-in-deferred-if/test.expected.jinja b/src/test/resources/eager/handles-import-in-deferred-if/test.expected.jinja new file mode 100644 index 000000000..b26dd23fc --- /dev/null +++ b/src/test/resources/eager/handles-import-in-deferred-if/test.expected.jinja @@ -0,0 +1,23 @@ +{% set primary_line_height = 100 %}\ +{% if deferred %} +{% do %}\ +{% set __temp_meta_current_path_724462665__,current_path = current_path,'eager/supplements/set-val.jinja' %}\ +{% set __temp_meta_import_alias_902286926__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% set primary_line_height = 42 %}\ +{% do __temp_meta_import_alias_902286926__.update({'primary_line_height': primary_line_height}) %}\ +{% do __temp_meta_import_alias_902286926__.update({'primary_line_height': 42,'import_resource_path': 'eager/supplements/set-val.jinja'}) %}\ +{% endfor %}\ +{% set simple = __temp_meta_import_alias_902286926__ %}\ +{% set current_path,__temp_meta_current_path_724462665__ = __temp_meta_current_path_724462665__,null %}\ +{% enddo %} +{% else %} +{% do %}\ +{% set __temp_meta_current_path_724462665__,current_path = current_path,'eager/supplements/set-val.jinja' %}\ +{% set primary_line_height = 42 %}\ +{% set current_path,__temp_meta_current_path_724462665__ = __temp_meta_current_path_724462665__,null %}\ +{% enddo %} +{% endif %} +simple.primary_line_height (deferred): {{ simple.primary_line_height }} +primary_line_height (deferred): {{ primary_line_height }} +secondary_line_height: 200 diff --git a/src/test/resources/eager/handles-import-in-deferred-if/test.jinja b/src/test/resources/eager/handles-import-in-deferred-if/test.jinja new file mode 100644 index 000000000..5cb4cb5da --- /dev/null +++ b/src/test/resources/eager/handles-import-in-deferred-if/test.jinja @@ -0,0 +1,10 @@ +{% set primary_line_height = 100 %} +{% set secondary_line_height = 200 %} +{% if deferred %} +{% import "../supplements/set-val.jinja" as simple %} +{% else %} +{% import "../supplements/set-val.jinja" %} +{% endif %} +simple.primary_line_height (deferred): {{ simple.primary_line_height }} +primary_line_height (deferred): {{ primary_line_height }} +secondary_line_height: {{ secondary_line_height }} diff --git a/src/test/resources/eager/handles-import-with-macros-in-deferred-if/test.jinja b/src/test/resources/eager/handles-import-with-macros-in-deferred-if/test.jinja new file mode 100644 index 000000000..956af0381 --- /dev/null +++ b/src/test/resources/eager/handles-import-with-macros-in-deferred-if/test.jinja @@ -0,0 +1,8 @@ +{% set myname = 'person' %} +{% if deferred %} +{%- import "../supplements/macro-and-set.jinja" as simple -%} +{% else %} +{%- import "../supplements/macro-and-set.jinja" as simple -%} +{% endif %} +simple.foo: {{ simple.foo() }} +simple.bar: {{ simple.bar }} diff --git a/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.expected.jinja b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.expected.jinja new file mode 100644 index 000000000..f0cc5ea31 --- /dev/null +++ b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.expected.jinja @@ -0,0 +1,3 @@ +item1 resolved.item1resolved +item2 resolved.item2resolved +item3 resolved.item3resolved diff --git a/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.jinja b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.jinja new file mode 100644 index 000000000..79193546c --- /dev/null +++ b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.expected.jinja @@ -0,0 +1,11 @@ +{% for __ignored__ in [0] %}\ +item1 {{ deferred }}\ +.{% set temp = 'item1' ~ deferred %}\ +{{ temp }} +item2 {{ deferred }}\ +.{% set temp = 'item2' ~ deferred %}\ +{{ temp }} +item3 {{ deferred }}\ +.{% set temp = 'item3' ~ deferred %}\ +{{ temp }} +{% endfor %} diff --git a/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.jinja b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.jinja new file mode 100644 index 000000000..9d0f2d522 --- /dev/null +++ b/src/test/resources/eager/handles-loop-var-against-deferred-in-loop/test.jinja @@ -0,0 +1,6 @@ +{% set items = ['item1', 'item2', 'item3'] %} +{%- for item in items -%} +{{ item }} {{ deferred }}. +{%- set temp = item ~ deferred -%} +{{ temp }} +{% endfor %} diff --git a/src/test/resources/eager/handles-modified-include-path/a.jinja b/src/test/resources/eager/handles-modified-include-path/a.jinja new file mode 100644 index 000000000..f110cc07a --- /dev/null +++ b/src/test/resources/eager/handles-modified-include-path/a.jinja @@ -0,0 +1,3 @@ +This is include a +{% if deferred %}{% set include_path = './b.jinja' %}{% endif %} +{% include include_path %} diff --git a/src/test/resources/eager/handles-modified-include-path/b.jinja b/src/test/resources/eager/handles-modified-include-path/b.jinja new file mode 100644 index 000000000..89dd18796 --- /dev/null +++ b/src/test/resources/eager/handles-modified-include-path/b.jinja @@ -0,0 +1 @@ +This is include b \ No newline at end of file diff --git a/src/test/resources/eager/handles-modified-include-path/test.expected.expected.jinja b/src/test/resources/eager/handles-modified-include-path/test.expected.expected.jinja new file mode 100644 index 000000000..41ccc3a6b --- /dev/null +++ b/src/test/resources/eager/handles-modified-include-path/test.expected.expected.jinja @@ -0,0 +1,6 @@ +Before include +This is include a + +This is include b + +After include \ No newline at end of file diff --git a/src/test/resources/eager/handles-modified-include-path/test.expected.jinja b/src/test/resources/eager/handles-modified-include-path/test.expected.jinja new file mode 100644 index 000000000..a42233536 --- /dev/null +++ b/src/test/resources/eager/handles-modified-include-path/test.expected.jinja @@ -0,0 +1,10 @@ +Before include +{% set __temp_meta_current_path_1035152568__,current_path = current_path,'eager/handles-modified-include-path/a.jinja' %}\ +This is include a +{% set include_path = './a.jinja' %}\ +{% if deferred %}\ +{% set include_path = './b.jinja' %}\ +{% endif %} +{% include include_path %} +{% set current_path,__temp_meta_current_path_1035152568__ = __temp_meta_current_path_1035152568__,null %} +After include \ No newline at end of file diff --git a/src/test/resources/eager/handles-modified-include-path/test.jinja b/src/test/resources/eager/handles-modified-include-path/test.jinja new file mode 100644 index 000000000..21ec080fc --- /dev/null +++ b/src/test/resources/eager/handles-modified-include-path/test.jinja @@ -0,0 +1,4 @@ +{% set include_path = './a.jinja' %} +Before include +{% include include_path %} +After include diff --git a/src/test/resources/eager/handles-non-deferred-import-vars/test.expected.jinja b/src/test/resources/eager/handles-non-deferred-import-vars/test.expected.jinja new file mode 100644 index 000000000..2d8a8c116 --- /dev/null +++ b/src/test/resources/eager/handles-non-deferred-import-vars/test.expected.jinja @@ -0,0 +1,5 @@ +foo: Hello 3 +bar: 22 +--- +simple.foo: Hello 7 +simple.bar: 26 diff --git a/src/test/resources/eager/handles-non-deferred-import-vars/test.jinja b/src/test/resources/eager/handles-non-deferred-import-vars/test.jinja new file mode 100644 index 000000000..37982ee09 --- /dev/null +++ b/src/test/resources/eager/handles-non-deferred-import-vars/test.jinja @@ -0,0 +1,9 @@ +{%- set myname = (1 + 2) -%} +{%- from "../supplements/macro-and-set.jinja" import foo, bar -%} +foo: {{ foo() }} +bar: {{ bar }} +--- +{% set myname = (3 + 4) -%} +{%- import "../supplements/macro-and-set.jinja" as simple -%} +simple.foo: {{ simple.foo() }} +simple.bar: {{ simple.bar }} diff --git a/src/test/resources/eager/handles-non-deferring-cycles/test.expected.jinja b/src/test/resources/eager/handles-non-deferring-cycles/test.expected.jinja new file mode 100644 index 000000000..3e2609131 --- /dev/null +++ b/src/test/resources/eager/handles-non-deferring-cycles/test.expected.jinja @@ -0,0 +1,20 @@ +start: start +foo: 1 +one/two: one +letter: a +start: start +foo: 2 +one/two: two +letter: b +start: start +foo: 3 +one/two: one +letter: c +start: start +foo: 4 +one/two: two +letter: a +start: start +foo: 5 +one/two: one +letter: b diff --git a/src/test/resources/eager/handles-non-deferring-cycles/test.jinja b/src/test/resources/eager/handles-non-deferring-cycles/test.jinja new file mode 100644 index 000000000..339d3b413 --- /dev/null +++ b/src/test/resources/eager/handles-non-deferring-cycles/test.jinja @@ -0,0 +1,8 @@ +{% set foo = ['1','2','3','4','5'] %} +{% for item in foo %} +start: {% cycle 'start' %} +foo: {% cycle foo %} +one/two: {% cycle 'one','two' %} +{% cycle 'a','b','c' as letter -%} +letter: {% cycle letter %} +{%- endfor -%} diff --git a/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.expected.jinja b/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.expected.jinja new file mode 100644 index 000000000..dccbe067b --- /dev/null +++ b/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.expected.jinja @@ -0,0 +1,19 @@ +{% set a_list = ['a'] %}\ +{% for __ignored__ in [0] %}\ +{% set b_list = a_list %}\ +{% do b_list.append(deferred) %} +{% endfor %} +{{ a_list }} +--- +{% for __ignored__ in [0] %} + +{% set a_list = [] %}\ +{% for __ignored__ in [0] %} +{% if deferred %} +{% set b_list = [] %} +{% set b_list = a_list %}\ +{% do b_list.append(1) %} +{% endif %} +{% endfor %} +{{ a_list }} +{% endfor %} diff --git a/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.jinja b/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.jinja new file mode 100644 index 000000000..53197b6d1 --- /dev/null +++ b/src/test/resources/eager/handles-reference-modification-when-source-is-lost/test.jinja @@ -0,0 +1,18 @@ +{% set a_list = [] %} +{%- do a_list.append('a') %} +{%- for i in range(1) %} +{%- set b_list = a_list %} +{%- do b_list.append(deferred) %} +{% endfor %} +{{ a_list }} +--- +{% for k in range(1) %} +{% set a_list = [] %} +{% for i in range(1) %} +{% if deferred %} +{% set b_list = a_list %} +{% do b_list.append(1) %} +{% endif %} +{% endfor %} +{{ a_list }} +{% endfor %} diff --git a/src/test/resources/eager/handles-same-name-import-var/set-var-and-deferred.jinja b/src/test/resources/eager/handles-same-name-import-var/set-var-and-deferred.jinja new file mode 100644 index 000000000..74989f05e --- /dev/null +++ b/src/test/resources/eager/handles-same-name-import-var/set-var-and-deferred.jinja @@ -0,0 +1,6 @@ +{% if deferred %} +{% do %}{% set path = 'eager/handles-same-name-import-var/set-var-and-deferred.jinja' %}{% set value = null %}{% set my_var = {} %}{% set my_var = {'foo': 'bar'} %}{% set my_var = {'my_var': my_var} %} +{% set value = deferred %}{% do my_var.update({"value": value}) %} +{% do my_var.update({'import_resource_path': 'eager/handles-same-name-import-var/set-var-and-deferred.jinja','value': value}) %}{% set path = '' %}{% enddo %} +{{ my_var }} +{% endif %} diff --git a/src/test/resources/eager/handles-same-name-import-var/test.expected.expected.jinja b/src/test/resources/eager/handles-same-name-import-var/test.expected.expected.jinja new file mode 100644 index 000000000..1baae9e13 --- /dev/null +++ b/src/test/resources/eager/handles-same-name-import-var/test.expected.expected.jinja @@ -0,0 +1 @@ +[fn:map_entry('import_resource_path', 'eager/handles-same-name-import-var/set-var-and-deferred.jinja'), fn:map_entry('my_var', {'my_var': {'foo': 'bar'} , 'value': 'resolved', 'import_resource_path': 'eager/handles-same-name-import-var/set-var-and-deferred.jinja'} ), fn:map_entry('path', ''), fn:map_entry('value', 'resolved')] \ No newline at end of file diff --git a/src/test/resources/eager/handles-same-name-import-var/test.expected.jinja b/src/test/resources/eager/handles-same-name-import-var/test.expected.jinja new file mode 100644 index 000000000..66cfa99f0 --- /dev/null +++ b/src/test/resources/eager/handles-same-name-import-var/test.expected.jinja @@ -0,0 +1,39 @@ +{% if deferred %} +{% do %}\ +{% set __temp_meta_current_path_944750549__,current_path = current_path,'eager/handles-same-name-import-var/set-var-and-deferred.jinja' %}\ +{% set __temp_meta_import_alias_1059697132__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% if deferred %} +{% do %}\ +{% set path = '' %}\ +{% do __temp_meta_import_alias_1059697132__.update({'path': path}) %}\ +{% set my_var = {'my_var': {'foo': 'bar'} } %}\ +{% do __temp_meta_import_alias_1059697132__.update({'my_var': my_var}) %}\ +{% set path = 'eager/handles-same-name-import-var/set-var-and-deferred.jinja' %}\ +{% do __temp_meta_import_alias_1059697132__.update({'path': path}) %}\ +{% set value = null %}\ +{% do __temp_meta_import_alias_1059697132__.update({'value': value}) %}\ +{% set my_var = {} %}\ +{% do __temp_meta_import_alias_1059697132__.update({'my_var': my_var}) %}\ +{% set my_var = {'foo': 'bar'} %}\ +{% do __temp_meta_import_alias_1059697132__.update({'my_var': my_var}) %}\ +{% set my_var = {'my_var': {'foo': 'bar'} } %}\ +{% do __temp_meta_import_alias_1059697132__.update({'my_var': my_var}) %} +{% set value = deferred %}\ +{% do __temp_meta_import_alias_1059697132__.update({'value': value}) %}\ +{% set my_var = {'my_var': {'foo': 'bar'} } %}\ +{% do __temp_meta_import_alias_1059697132__.update({'my_var': my_var}) %}\ +{% do my_var.update({'value': value}) %} +{% do my_var.update({'import_resource_path': 'eager/handles-same-name-import-var/set-var-and-deferred.jinja', 'value': value}) %}\ +{% set path = '' %}\ +{% do __temp_meta_import_alias_1059697132__.update({'path': path}) %}\ +{% enddo %} +{{ my_var }} +{% endif %} +{% do __temp_meta_import_alias_1059697132__.update({'path': path,'import_resource_path': 'eager/handles-same-name-import-var/set-var-and-deferred.jinja','value': value}) %}\ +{% endfor %}\ +{% set my_var = __temp_meta_import_alias_1059697132__ %}\ +{% set current_path,__temp_meta_current_path_944750549__ = __temp_meta_current_path_944750549__,null %}\ +{% enddo %} +{{ filter:dictsort.filter(my_var, ____int3rpr3t3r____, false, 'key') }} +{% endif %} diff --git a/src/test/resources/eager/handles-same-name-import-var/test.jinja b/src/test/resources/eager/handles-same-name-import-var/test.jinja new file mode 100644 index 000000000..efdfeed46 --- /dev/null +++ b/src/test/resources/eager/handles-same-name-import-var/test.jinja @@ -0,0 +1,4 @@ +{% if deferred %} +{% import './set-var-and-deferred.jinja' as my_var %} +{{ my_var|dictsort(false, 'key') }} +{% endif %} diff --git a/src/test/resources/eager/handles-set-and-modified-in-for/test.expected.jinja b/src/test/resources/eager/handles-set-and-modified-in-for/test.expected.jinja new file mode 100644 index 000000000..63d333221 --- /dev/null +++ b/src/test/resources/eager/handles-set-and-modified-in-for/test.expected.jinja @@ -0,0 +1,10 @@ +{% set list = [] %}\ +{% for item in deferred %} +{% unless count %} +{% set count = 0 %} +{% endunless %} +{% do list.append(count) %} +{% set count = count + 1 %} +{{ count }} +{% endfor %} +{{ list }} diff --git a/src/test/resources/eager/handles-set-and-modified-in-for/test.jinja b/src/test/resources/eager/handles-set-and-modified-in-for/test.jinja new file mode 100644 index 000000000..57f62c3fc --- /dev/null +++ b/src/test/resources/eager/handles-set-and-modified-in-for/test.jinja @@ -0,0 +1,10 @@ +{% set list = [] %} +{% for item in deferred %} +{% unless count %} +{% set count = 0 %} +{% endunless %} +{% do list.append(count) %} +{% set count = count + 1 %} +{{ count }} +{% endfor %} +{{ list }} diff --git a/src/test/resources/eager/handles-set-in-for/test.expected.jinja b/src/test/resources/eager/handles-set-in-for/test.expected.jinja new file mode 100644 index 000000000..1278f7a47 --- /dev/null +++ b/src/test/resources/eager/handles-set-in-for/test.expected.jinja @@ -0,0 +1,8 @@ +{% set list = [] %}\ +{% for item in deferred %} +{% set count = 0 %} +{% do list.append(0) %} +{% set count = 1 %} +1 +{% endfor %} +{{ list }} diff --git a/src/test/resources/eager/handles-set-in-for/test.jinja b/src/test/resources/eager/handles-set-in-for/test.jinja new file mode 100644 index 000000000..613963afe --- /dev/null +++ b/src/test/resources/eager/handles-set-in-for/test.jinja @@ -0,0 +1,8 @@ +{% set list = [] %} +{% for item in deferred %} +{% set count = 0 %} +{% do list.append(count) %} +{% set count = count + 1 %} +{{ count }} +{% endfor %} +{{ list }} diff --git a/src/test/resources/eager/handles-set-in-inner-scope/test.expected.jinja b/src/test/resources/eager/handles-set-in-inner-scope/test.expected.jinja new file mode 100644 index 000000000..84ead841d --- /dev/null +++ b/src/test/resources/eager/handles-set-in-inner-scope/test.expected.jinja @@ -0,0 +1,5 @@ +{% for __ignored__ in [0] %} +{% set foo = deferred %} +{{ foo }} +{% endfor %} +1 diff --git a/src/test/resources/eager/handles-set-in-inner-scope/test.jinja b/src/test/resources/eager/handles-set-in-inner-scope/test.jinja new file mode 100644 index 000000000..760d9ff3a --- /dev/null +++ b/src/test/resources/eager/handles-set-in-inner-scope/test.jinja @@ -0,0 +1,6 @@ +{% set foo = 1 %} +{% for i in range(1) %} +{% set foo = deferred %} +{{ foo }} +{% endfor %} +{{ foo }} diff --git a/src/test/resources/eager/handles-unknown-function-errors/test.expected.jinja b/src/test/resources/eager/handles-unknown-function-errors/test.expected.jinja new file mode 100644 index 000000000..73b323a91 --- /dev/null +++ b/src/test/resources/eager/handles-unknown-function-errors/test.expected.jinja @@ -0,0 +1,2 @@ +Some () expression +Nothing: () diff --git a/src/test/resources/eager/handles-unknown-function-errors/test.jinja b/src/test/resources/eager/handles-unknown-function-errors/test.jinja new file mode 100644 index 000000000..c9887a7b3 --- /dev/null +++ b/src/test/resources/eager/handles-unknown-function-errors/test.jinja @@ -0,0 +1,3 @@ +Some ({{ unknown(null) }}) expression +{% set foo = unknown(null) -%} +Nothing: ({{ foo }}) diff --git a/src/test/resources/eager/handles-value-modified-in-macro/test.expected.jinja b/src/test/resources/eager/handles-value-modified-in-macro/test.expected.jinja new file mode 100644 index 000000000..ed3cd4b73 --- /dev/null +++ b/src/test/resources/eager/handles-value-modified-in-macro/test.expected.jinja @@ -0,0 +1,17 @@ +{% macro counter(foo) %} +{% set level = level + 2 %} +{% if level < foo %} +{{ counter(foo) }} +{% endif %} +{{ level }} +{% endmacro %}\ +{{ counter(deferred) }} + +{% macro counter(foo) %} +{% set level = level + 2 %} +{% if level < foo %} +{{ counter(foo) }} +{% endif %} +{{ level }} +{% endmacro %}\ +{{ counter(2) }} diff --git a/src/test/resources/eager/handles-value-modified-in-macro/test.jinja b/src/test/resources/eager/handles-value-modified-in-macro/test.jinja new file mode 100644 index 000000000..c4dcbb32d --- /dev/null +++ b/src/test/resources/eager/handles-value-modified-in-macro/test.jinja @@ -0,0 +1,11 @@ +{% set increment = 2 %} +{% macro counter(foo) %} +{% set level = level + increment %} +{% if level < foo %} +{{ counter(foo) }} +{% endif %} +{{ level }} +{% endmacro %} +{{ counter(deferred) }} + +{{ counter(2) }} diff --git a/src/test/resources/eager/has-proper-line-stripping/test.expected.jinja b/src/test/resources/eager/has-proper-line-stripping/test.expected.jinja new file mode 100644 index 000000000..fe4d2dad2 --- /dev/null +++ b/src/test/resources/eager/has-proper-line-stripping/test.expected.jinja @@ -0,0 +1,8 @@ +1 +2 +3{% if deferred > 0 %}\ +{{ deferred }}\ +{% elif deferred == 0 %}\ +null{% else %}\ +{{ deferred }}\ +{% endif %} diff --git a/src/test/resources/eager/has-proper-line-stripping/test.jinja b/src/test/resources/eager/has-proper-line-stripping/test.jinja new file mode 100644 index 000000000..0def88054 --- /dev/null +++ b/src/test/resources/eager/has-proper-line-stripping/test.jinja @@ -0,0 +1,10 @@ +1 +{% do 1 -%} +2 +3{% if deferred > 0 -%} +{{ deferred }} +{%- elif deferred == 0 -%} +null +{%- else -%} +{{ deferred }} +{%- endif %} diff --git a/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.expected.jinja b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.expected.jinja new file mode 100644 index 000000000..23d81650a --- /dev/null +++ b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.expected.jinja @@ -0,0 +1,7 @@ +1 +2 +3 +3 +2 +3 +3 diff --git a/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.jinja b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.jinja new file mode 100644 index 000000000..98ddd3fac --- /dev/null +++ b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.expected.jinja @@ -0,0 +1,35 @@ +{% set list = [] %}\ +{% if deferred %} + +{% set __macro_inc_100372882_temp_variable_0__ %}\ +{% do list.append(1) %}\ +1{% set depth = 2 %} +{% set __macro_inc_100372882_temp_variable_1__ %}\ +{% do list.append(2) %}\ +2{% set depth = 3 %} +{% set __macro_inc_100372882_temp_variable_2__ %}\ +{% do list.append(3) %}\ +3{% endset %}\ +{{ __macro_inc_100372882_temp_variable_2__ }} +{% set __macro_inc_100372882_temp_variable_3__ %}\ +{% do list.append(3) %}\ +3{% endset %}\ +{{ __macro_inc_100372882_temp_variable_3__ }}\ +{% endset %}\ +{{ __macro_inc_100372882_temp_variable_1__ }} +{% set __macro_inc_100372882_temp_variable_4__ %}\ +{% do list.append(2) %}\ +2{% set depth = 3 %} +{% set __macro_inc_100372882_temp_variable_5__ %}\ +{% do list.append(3) %}\ +3{% endset %}\ +{{ __macro_inc_100372882_temp_variable_5__ }} +{% set __macro_inc_100372882_temp_variable_6__ %}\ +{% do list.append(3) %}\ +3{% endset %}\ +{{ __macro_inc_100372882_temp_variable_6__ }}\ +{% endset %}\ +{{ __macro_inc_100372882_temp_variable_4__ }}\ +{% endset %}\ +{{ __macro_inc_100372882_temp_variable_0__ }} +{% endif %} diff --git a/src/test/resources/eager/keeps-macro-modifications-in-scope/test.jinja b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.jinja new file mode 100644 index 000000000..89423a419 --- /dev/null +++ b/src/test/resources/eager/keeps-macro-modifications-in-scope/test.jinja @@ -0,0 +1,15 @@ +{%- set list = [] %} +{%- if deferred %} +{%- macro inc(val, depth) %} +{%- do list.append(depth) -%} +{{ depth }} +{%- if depth < 3 %} +{%- set depth = depth + 1 %} +{{ inc(val, depth) }} +{{ inc(val, depth) }} +{%- endif %} +{%- endmacro %} + +{{ inc('a', 1) }} +{% endif %} + diff --git a/src/test/resources/eager/keeps-max-macro-recursion-depth/test.expected.jinja b/src/test/resources/eager/keeps-max-macro-recursion-depth/test.expected.jinja new file mode 100644 index 000000000..4cc9dc3e9 --- /dev/null +++ b/src/test/resources/eager/keeps-max-macro-recursion-depth/test.expected.jinja @@ -0,0 +1,4 @@ +3 +2 +1 +0 diff --git a/src/test/resources/eager/keeps-max-macro-recursion-depth/test.jinja b/src/test/resources/eager/keeps-max-macro-recursion-depth/test.jinja new file mode 100644 index 000000000..30896cfe1 --- /dev/null +++ b/src/test/resources/eager/keeps-max-macro-recursion-depth/test.jinja @@ -0,0 +1,7 @@ +{% macro rec(n) -%} +{{ n }} +{% if n > 0 -%} +{{ rec(n-1) }} +{%- endif -%} +{% endmacro %} +{{ rec(3) }} diff --git a/src/test/resources/eager/keeps-meta-context-variables-through-import/import-target.jinja b/src/test/resources/eager/keeps-meta-context-variables-through-import/import-target.jinja new file mode 100644 index 000000000..707978124 --- /dev/null +++ b/src/test/resources/eager/keeps-meta-context-variables-through-import/import-target.jinja @@ -0,0 +1 @@ +I am a boring import \ No newline at end of file diff --git a/src/test/resources/eager/keeps-meta-context-variables-through-import/test.expected.jinja b/src/test/resources/eager/keeps-meta-context-variables-through-import/test.expected.jinja new file mode 100644 index 000000000..d44133a16 --- /dev/null +++ b/src/test/resources/eager/keeps-meta-context-variables-through-import/test.expected.jinja @@ -0,0 +1,5 @@ +{% set list = deferred %} + +{% set meta = ['overridden'] %}\ +{% do list.append(meta) %} +{{ list }} \ No newline at end of file diff --git a/src/test/resources/eager/keeps-meta-context-variables-through-import/test.jinja b/src/test/resources/eager/keeps-meta-context-variables-through-import/test.jinja new file mode 100644 index 000000000..5b1adc00c --- /dev/null +++ b/src/test/resources/eager/keeps-meta-context-variables-through-import/test.jinja @@ -0,0 +1,5 @@ +{% set meta = ['overridden'] %} +{% set list = deferred %} +{% import '../../eager/keeps-meta-context-variables-through-import/import-target.jinja' %} +{% do list.append(meta) %} +{{ list }} \ No newline at end of file diff --git a/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.expected.jinja b/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.expected.jinja new file mode 100644 index 000000000..08b12367f --- /dev/null +++ b/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.expected.jinja @@ -0,0 +1,15 @@ +{% for i in range(deferred) %} +{% set foo = 'a' ~ i %} +{% for j in range(deferred) %} +{% set foo = 'b' ~ j %} +{% for __ignored__ in [0] %} +{% set foo = 'c0' %} +c0 + +{% set foo = 'c1' %} +c1 +{% endfor %} +{{ foo }} +{% endfor %} +{{ foo }} +{% endfor %} diff --git a/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.jinja b/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.jinja new file mode 100644 index 000000000..a024df133 --- /dev/null +++ b/src/test/resources/eager/keeps-scope-isolation-from-for-loops/test.jinja @@ -0,0 +1,12 @@ +{% for i in range(deferred) %} +{% set foo = 'a' ~ i %} +{% for j in range(deferred) %} +{% set foo = 'b' ~ j %} +{% for k in range(2) %} +{% set foo = 'c' ~ k %} +{{ foo }} +{% endfor %} +{{ foo }} +{% endfor %} +{{ foo }} +{% endfor %} diff --git a/src/test/resources/eager/loads-imported-macro-syntax/test.expected.jinja b/src/test/resources/eager/loads-imported-macro-syntax/test.expected.jinja new file mode 100644 index 000000000..e456677a0 --- /dev/null +++ b/src/test/resources/eager/loads-imported-macro-syntax/test.expected.jinja @@ -0,0 +1 @@ +Lemonade diff --git a/src/test/resources/eager/loads-imported-macro-syntax/test.jinja b/src/test/resources/eager/loads-imported-macro-syntax/test.jinja new file mode 100644 index 000000000..4db3c0c9e --- /dev/null +++ b/src/test/resources/eager/loads-imported-macro-syntax/test.jinja @@ -0,0 +1,4 @@ +{%- macro a.b() -%} +Lemonade +{% endmacro %} +{{ a.b() }} diff --git a/src/test/resources/eager/modifies-variable-in-deferred-macro/test.expected.jinja b/src/test/resources/eager/modifies-variable-in-deferred-macro/test.expected.jinja new file mode 100644 index 000000000..431eba66b --- /dev/null +++ b/src/test/resources/eager/modifies-variable-in-deferred-macro/test.expected.jinja @@ -0,0 +1,20 @@ +{% macro my_macro(list, other) %} +{% do list.append(other) %} +{% endmacro %}\ +{% set list = [] %}\ +{% do my_macro(list, deferred) %} +{{ list }} + +{% macro my_macro(list, other) %} +{% do list.append(other) %} +{% endmacro %}\ +{% set var = [] %}\ +{% do my_macro(var, deferred) %} +{{ var }} + +{% macro my_macro(list, other) %} +{% do list.append(other) %} +{% endmacro %}\ +{% set var2 = [] %}\ +{% do my_macro(var2, 1) %} +{{ var2 }} diff --git a/src/test/resources/eager/modifies-variable-in-deferred-macro/test.jinja b/src/test/resources/eager/modifies-variable-in-deferred-macro/test.jinja new file mode 100644 index 000000000..76e8416ad --- /dev/null +++ b/src/test/resources/eager/modifies-variable-in-deferred-macro/test.jinja @@ -0,0 +1,12 @@ +{% macro my_macro(list, other) %} +{% do list.append(other) %} +{% endmacro %} +{% set list = [] %} +{% do my_macro(list, deferred) %} +{{ list }} +{% set var = [] %} +{% do my_macro(var, deferred) %} +{{ var }} +{% set var2 = [] %} +{% do my_macro(var2, 1) %} +{{ var2 }} diff --git a/src/test/resources/eager/only-defers-loop-item-on-current-context/test.expected.jinja b/src/test/resources/eager/only-defers-loop-item-on-current-context/test.expected.jinja new file mode 100644 index 000000000..760c22ba2 --- /dev/null +++ b/src/test/resources/eager/only-defers-loop-item-on-current-context/test.expected.jinja @@ -0,0 +1,15 @@ +{% set outer_val = 'start' %}\ +{% for def in deferred %} +{% set outer_list = [{'a': ['b']} ] %} +{% for __ignored__ in [0] %} +{% set map = {'a': ['b']} %}\ +{% set inner_list = map.a %} +{% for x in inner_list %} + +{% set outer_val = outer_val ~ '-' %} +{% if outer_val == deferred %} +{{ outer_val }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/src/test/resources/eager/only-defers-loop-item-on-current-context/test.jinja b/src/test/resources/eager/only-defers-loop-item-on-current-context/test.jinja new file mode 100644 index 000000000..3bc01a0a5 --- /dev/null +++ b/src/test/resources/eager/only-defers-loop-item-on-current-context/test.jinja @@ -0,0 +1,14 @@ +{% set outer_val = 'start' %} +{% for def in deferred %} +{% set outer_list = [{'a': ['b']}] %} +{% for map in outer_list %} +{% set inner_list = map.a %} +{% for x in inner_list %} +{# make outer_val a speculative binding #} +{% set outer_val = outer_val ~ '-' %} +{% if outer_val == deferred %} +{{ outer_val }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} \ No newline at end of file diff --git a/src/test/resources/eager/partially-resolves-eager-set/test.jinja b/src/test/resources/eager/partially-resolves-eager-set/test.jinja new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/eager/prepends-set-if-state-changes/test.expected.jinja b/src/test/resources/eager/prepends-set-if-state-changes/test.expected.jinja new file mode 100644 index 000000000..102f5477a --- /dev/null +++ b/src/test/resources/eager/prepends-set-if-state-changes/test.expected.jinja @@ -0,0 +1,7 @@ +{% set foo = [0] %}\ +{% set deferred = deferred && foo.append(1) %} +{% do foo.append(2) %} +{% set deferred = deferred && foo.append(3) %} +{% print foo.append(4) %} +{% set deferred = deferred && foo.append(5) %} +{{ foo }} diff --git a/src/test/resources/eager/prepends-set-if-state-changes/test.jinja b/src/test/resources/eager/prepends-set-if-state-changes/test.jinja new file mode 100644 index 000000000..3ce31d7b8 --- /dev/null +++ b/src/test/resources/eager/prepends-set-if-state-changes/test.jinja @@ -0,0 +1,7 @@ +{% set foo = [0] %} +{% set deferred = deferred && foo.append(1) %} +{% do foo.append(2) %} +{% set deferred = deferred && foo.append(3) %} +{% print foo.append(4) %} +{% set deferred = deferred && foo.append(5) %} +{{ foo }} diff --git a/src/test/resources/eager/preserves-blocks-for-reconstruction-order/base.jinja b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/base.jinja new file mode 100644 index 000000000..438fedb55 --- /dev/null +++ b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/base.jinja @@ -0,0 +1,13 @@ +{% if deferred %} +{% set deferred_list = [] %} +{% endif %} +{% do deferred_list.append('Before block') %} +Deferred list after block is: {{ deferred_list }} +Deferred list after block should be: ['Before block'] +-----Pre-First----- +{% block first -%} +{%- endblock %} +-----Post-First----- +{% do deferred_list.append('After block') %} +Deferred list after block is: {{ deferred_list }} +Deferred list after block should be: ['Before block', 'After block'] diff --git a/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.expected.jinja b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.expected.jinja new file mode 100644 index 000000000..82ee6830e --- /dev/null +++ b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.expected.jinja @@ -0,0 +1,12 @@ +Deferred list after block is: ['Before block'] +Deferred list after block should be: ['Before block'] +-----Pre-First----- + + +Deferred list inside of block is: ['Before block', 'After block', 'In child block'] +Deferred list inside of block should be: ['Before block', 'After block', 'In child block'] + +-----Post-First----- + +Deferred list after block is: ['Before block', 'After block'] +Deferred list after block should be: ['Before block', 'After block'] \ No newline at end of file diff --git a/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.jinja b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.jinja new file mode 100644 index 000000000..7eccab5cf --- /dev/null +++ b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.expected.jinja @@ -0,0 +1,19 @@ +{% set current_path = 'eager/preserves-blocks-for-reconstruction-order/base.jinja' %}\ +{% if deferred %} +{% set deferred_list = [] %} +{% endif %} +{% do deferred_list.append('Before block') %} +Deferred list after block is: {{ deferred_list }} +Deferred list after block should be: ['Before block'] +-----Pre-First----- +{% block first %}\ +{% set __temp_meta_current_path_1012932725__,current_path = current_path,'eager/preserves-blocks-for-reconstruction-order/test.jinja' %} +{% do deferred_list.append('In child block') %} +Deferred list inside of block is: {{ deferred_list }} +Deferred list inside of block should be: ['Before block', 'After block', 'In child block'] +{% set current_path,__temp_meta_current_path_1012932725__ = __temp_meta_current_path_1012932725__,null %}\ +{% endblock first %} +-----Post-First----- +{% do deferred_list.append('After block') %} +Deferred list after block is: {{ deferred_list }} +Deferred list after block should be: ['Before block', 'After block'] \ No newline at end of file diff --git a/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.jinja b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.jinja new file mode 100644 index 000000000..40cfc0105 --- /dev/null +++ b/src/test/resources/eager/preserves-blocks-for-reconstruction-order/test.jinja @@ -0,0 +1,7 @@ +{% extends '../../eager/preserves-blocks-for-reconstruction-order/base.jinja' %} + +{% block first %} +{% do deferred_list.append('In child block') %} +Deferred list inside of block is: {{ deferred_list }} +Deferred list inside of block should be: ['Before block', 'After block', 'In child block'] +{% endblock %} diff --git a/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.expected.jinja b/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.expected.jinja new file mode 100644 index 000000000..eb79d845d --- /dev/null +++ b/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.expected.jinja @@ -0,0 +1,10 @@ +{% set foo %} +{% if deferred %} +{% raw %}\ +{{ 'fire' }}\ +{% endraw %} +{% else %} +water +{% endif %} +{% endset %} +{% print foo %} diff --git a/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.jinja b/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.jinja new file mode 100644 index 000000000..df246bfc0 --- /dev/null +++ b/src/test/resources/eager/preserves-raw-inside-deferred-set-block/test.jinja @@ -0,0 +1,8 @@ +{% set foo %} +{% if deferred %} +{% raw %}{{ 'fire' }}{% endraw %} +{% else %} +{{ 'water' }} +{% endif %} +{% endset %} +{% print foo %} diff --git a/src/test/resources/eager/preserves-value-set-in-if/test.expected.jinja b/src/test/resources/eager/preserves-value-set-in-if/test.expected.jinja new file mode 100644 index 000000000..b59f49423 --- /dev/null +++ b/src/test/resources/eager/preserves-value-set-in-if/test.expected.jinja @@ -0,0 +1,6 @@ +2 +{% set foo = 2 %}\ +{% if deferred %} +{% set foo = deferred %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/preserves-value-set-in-if/test.jinja b/src/test/resources/eager/preserves-value-set-in-if/test.jinja new file mode 100644 index 000000000..e34006f5a --- /dev/null +++ b/src/test/resources/eager/preserves-value-set-in-if/test.jinja @@ -0,0 +1,5 @@ +2 +{% set foo = 2 %}{% if deferred %} +{% set foo = deferred %} +{% endif %} +{{ foo }} diff --git a/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.expected.jinja b/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.expected.jinja new file mode 100644 index 000000000..571f118c8 --- /dev/null +++ b/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.expected.jinja @@ -0,0 +1,5 @@ +{% set myname = deferred + 3 %}\ +{% set __macro_getPath_1519775617_temp_variable_1__ %}\ +Hello {{ myname }}\ +{% endset %}\ +{% print __macro_getPath_1519775617_temp_variable_1__ %} diff --git a/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.jinja b/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.jinja new file mode 100644 index 000000000..4f01f9dbf --- /dev/null +++ b/src/test/resources/eager/puts-deferred-fromed-macro-in-output/test.jinja @@ -0,0 +1,3 @@ +{%- from "../supplements/simple-with-call.jinja" import getPath -%} +{%- set myname = deferred + (1 + 2) -%} +{% print getPath() %} diff --git a/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.expected.jinja b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.expected.jinja new file mode 100644 index 000000000..29a69b2ec --- /dev/null +++ b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.expected.jinja @@ -0,0 +1 @@ +Hello 4 diff --git a/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.jinja b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.jinja new file mode 100644 index 000000000..571f118c8 --- /dev/null +++ b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.expected.jinja @@ -0,0 +1,5 @@ +{% set myname = deferred + 3 %}\ +{% set __macro_getPath_1519775617_temp_variable_1__ %}\ +Hello {{ myname }}\ +{% endset %}\ +{% print __macro_getPath_1519775617_temp_variable_1__ %} diff --git a/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.jinja b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.jinja new file mode 100644 index 000000000..63415185d --- /dev/null +++ b/src/test/resources/eager/puts-deferred-imported-macro-in-output/test.jinja @@ -0,0 +1,3 @@ +{%- import "../supplements/simple-with-call.jinja" as simple -%} +{%- set myname = deferred + (1 + 2) -%} +{% print simple.getPath() %} diff --git a/src/test/resources/eager/reconstructs-aliased-macro/takes-param.jinja b/src/test/resources/eager/reconstructs-aliased-macro/takes-param.jinja new file mode 100644 index 000000000..b77c4ee4c --- /dev/null +++ b/src/test/resources/eager/reconstructs-aliased-macro/takes-param.jinja @@ -0,0 +1,5 @@ +{% macro takes_param(foo) %} +{% print foo %} +{% endmacro %} + +{% set bar = 'bar' %} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-aliased-macro/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-aliased-macro/test.expected.expected.jinja new file mode 100644 index 000000000..4c218176d --- /dev/null +++ b/src/test/resources/eager/reconstructs-aliased-macro/test.expected.expected.jinja @@ -0,0 +1 @@ +resolved3 diff --git a/src/test/resources/eager/reconstructs-aliased-macro/test.expected.jinja b/src/test/resources/eager/reconstructs-aliased-macro/test.expected.jinja new file mode 100644 index 000000000..43d9267c9 --- /dev/null +++ b/src/test/resources/eager/reconstructs-aliased-macro/test.expected.jinja @@ -0,0 +1,9 @@ +{% set myname = deferred + 3 %}\ +{% set deferred_import_resource_path = 'eager/reconstructs-aliased-macro/takes-param.jinja' %}\ +{% macro macros.takes_param(foo) %}\ +{% set bar = 'bar' %} +{% print foo %} +{% endmacro %}\ +{% set deferred_import_resource_path = null %}\ +{% set answer = macros.takes_param(myname) %} +{{ answer }} diff --git a/src/test/resources/eager/reconstructs-aliased-macro/test.jinja b/src/test/resources/eager/reconstructs-aliased-macro/test.jinja new file mode 100644 index 000000000..a1326a645 --- /dev/null +++ b/src/test/resources/eager/reconstructs-aliased-macro/test.jinja @@ -0,0 +1,4 @@ +{%- import "./takes-param.jinja" as macros -%} +{%- set myname = deferred + (1 + 2) -%} +{% set answer = macros.takes_param(myname) %} +{{ answer }} diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/base.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/base.jinja new file mode 100644 index 000000000..4514f4c22 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/base.jinja @@ -0,0 +1,11 @@ +{% set prefix = deferred ? "current" : "current" %} +Parent's current path is: {{ '{{' + prefix + '_path }}' }} +-----Pre-First----- +{% block first -%} +{%- endblock %} +-----Post-First----- +-----Pre-Second----- +{% block second -%} +{%- endblock %} +-----Post-Second----- +Parent's current path is: {{ '{{' + prefix + '_path }}' }} diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/middle.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/middle.jinja new file mode 100644 index 000000000..791bbc101 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/middle.jinja @@ -0,0 +1,10 @@ +{% extends '../../eager/reconstructs-block-path-when-deferred-nested/base.jinja' %} +{% block first %} +{%- set prefix = deferred ? "current" : "current" -%} +Middle's first current path is: {{ '{{' + prefix + '_path }}' }} +{% endblock %} + +{% block second %} +{%- set prefix = deferred ? "current" : "current" -%} +Middle's second current path is: {{ '{{' + prefix + '_path }}' }} +{% endblock %} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.expected.jinja new file mode 100644 index 000000000..ba93542f5 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.expected.jinja @@ -0,0 +1,10 @@ +Parent's current path is: eager/reconstructs-block-path-when-deferred-nested/base.jinja +-----Pre-First----- +Child's first current path is: eager/reconstructs-block-path-when-deferred-nested/test.jinja + +-----Post-First----- +-----Pre-Second----- +Middle's second current path is: eager/reconstructs-block-path-when-deferred-nested/middle.jinja + +-----Post-Second----- +Parent's current path is: eager/reconstructs-block-path-when-deferred-nested/base.jinja \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.jinja new file mode 100644 index 000000000..b81a5e8a6 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.expected.jinja @@ -0,0 +1,24 @@ +{% set current_path = 'eager/reconstructs-block-path-when-deferred-nested/base.jinja' %}\ +{% set prefix = deferred ? 'current' : 'current' %} +Parent's current path is: {{ '{{' + prefix + '_path }}\ +' }} +-----Pre-First----- +{% block first %}\ +{% set __temp_meta_current_path_389897147__,current_path = current_path,'eager/reconstructs-block-path-when-deferred-nested/test.jinja' %}\ +{% set prefix = deferred ? 'current' : 'current' %}\ +Child's first current path is: {{ '{{' + prefix + '_path }}\ +' }} +{% set current_path,__temp_meta_current_path_389897147__ = __temp_meta_current_path_389897147__,null %}\ +{% endblock first %} +-----Post-First----- +-----Pre-Second----- +{% block second %}\ +{% set __temp_meta_current_path_198396781__,current_path = current_path,'eager/reconstructs-block-path-when-deferred-nested/middle.jinja' %}\ +{% set prefix = deferred ? 'current' : 'current' %}\ +Middle's second current path is: {{ '{{' + prefix + '_path }}\ +' }} +{% set current_path,__temp_meta_current_path_198396781__ = __temp_meta_current_path_198396781__,null %}\ +{% endblock second %} +-----Post-Second----- +Parent's current path is: {{ '{{' + prefix + '_path }}\ +' }} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.jinja new file mode 100644 index 000000000..882a8cb22 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred-nested/test.jinja @@ -0,0 +1,5 @@ +{% extends '../../eager/reconstructs-block-path-when-deferred-nested/middle.jinja' %} +{% block first %} +{%- set prefix = deferred ? "current" : "current" -%} +Child's first current path is: {{ '{{' + prefix + '_path }}' }} +{% endblock %} diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred/base.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred/base.jinja new file mode 100644 index 000000000..82268f242 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred/base.jinja @@ -0,0 +1,7 @@ +{% set prefix = deferred ? "current" : "current" %} +Parent's current path is: {{ '{{' + prefix + '_path }}' }} +-----Pre-Block----- +{% block body -%} +{%- endblock %} +-----Post-Block----- +Parent's current path is: {{ '{{' + prefix + '_path }}' }} diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.expected.jinja new file mode 100644 index 000000000..8cd850b99 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.expected.jinja @@ -0,0 +1,6 @@ +Parent's current path is: eager/reconstructs-block-path-when-deferred/base.jinja +-----Pre-Block----- +Block's current path is: eager/reconstructs-block-path-when-deferred/test.jinja + +-----Post-Block----- +Parent's current path is: eager/reconstructs-block-path-when-deferred/base.jinja diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.jinja new file mode 100644 index 000000000..6c3a8e954 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.expected.jinja @@ -0,0 +1,15 @@ +{% set current_path = 'eager/reconstructs-block-path-when-deferred/base.jinja' %}\ +{% set prefix = deferred ? 'current' : 'current' %} +Parent's current path is: {{ '{{' + prefix + '_path }}\ +' }} +-----Pre-Block----- +{% block body %}\ +{% set __temp_meta_current_path_329664044__,current_path = current_path,'eager/reconstructs-block-path-when-deferred/test.jinja' %}\ +{% set prefix = deferred ? 'current' : 'current' %}\ +Block's current path is: {{ '{{' + prefix + '_path }}\ +' }} +{% set current_path,__temp_meta_current_path_329664044__ = __temp_meta_current_path_329664044__,null %}\ +{% endblock body %} +-----Post-Block----- +Parent's current path is: {{ '{{' + prefix + '_path }}\ +' }} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-block-path-when-deferred/test.jinja b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.jinja new file mode 100644 index 000000000..7280f3165 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-path-when-deferred/test.jinja @@ -0,0 +1,5 @@ +{% extends '../../eager/reconstructs-block-path-when-deferred/base.jinja' %} +{% block body %} +{%- set prefix = deferred ? "current" : "current" -%} +Block's current path is: {{ '{{' + prefix + '_path }}' }} +{% endblock %} diff --git a/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.expected.jinja b/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.expected.jinja new file mode 100644 index 000000000..d0152a7f9 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.expected.jinja @@ -0,0 +1,6 @@ +{% for i in range(deferred) %} +{% set __macro_foo_97643642_temp_variable_0__ %} +{{ deferred }} +{% endset %}\ +{{ filter:int.filter(__macro_foo_97643642_temp_variable_0__, ____int3rpr3t3r____) + 3 }} +{% endfor %} diff --git a/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.jinja b/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.jinja new file mode 100644 index 000000000..443054813 --- /dev/null +++ b/src/test/resources/eager/reconstructs-block-set-variables-in-for-loop/test.jinja @@ -0,0 +1,6 @@ +{% for i in range(deferred) %} +{%- macro foo() %} +{{ deferred }} +{% endmacro %} +{{ foo()|int + 3 }} +{% endfor %} diff --git a/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.expected.jinja b/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.expected.jinja new file mode 100644 index 000000000..cf2e3900d --- /dev/null +++ b/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.expected.jinja @@ -0,0 +1,17 @@ +{% set my_list = [] %}\ +{% set __macro_append_stuff_153654787_temp_variable_0__ %} +{% if deferred %} + +{% set __macro_foo_97643642_temp_variable_0__ %} +{% do my_list.append('b') %} +{% endset %}\ +{{ __macro_foo_97643642_temp_variable_0__ }} +{% set __macro_foo_97643642_temp_variable_1__ %} +{% do my_list.append('c') %} +{% endset %}\ +{{ __macro_foo_97643642_temp_variable_1__ }} +{% endif %} +{% endset %}\ +{{ __macro_append_stuff_153654787_temp_variable_0__ }} + +{{ my_list }} diff --git a/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.jinja b/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.jinja new file mode 100644 index 000000000..8379c8fd4 --- /dev/null +++ b/src/test/resources/eager/reconstructs-deferred-variable-eventually/test.jinja @@ -0,0 +1,16 @@ +{% macro foo(var) %} +{% do my_list.append(var) %} +{% endmacro %} + +{% macro append_stuff() %} +{% if deferred %} + +{{ foo('b') }} +{{ foo('c') }} +{% endif %} +{% endmacro %} + +{% set my_list = [] %} +{{ append_stuff() }} + +{{ my_list }} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-fromed-macro/has-macro.jinja b/src/test/resources/eager/reconstructs-fromed-macro/has-macro.jinja new file mode 100644 index 000000000..f08325779 --- /dev/null +++ b/src/test/resources/eager/reconstructs-fromed-macro/has-macro.jinja @@ -0,0 +1,3 @@ +{% macro upper(param) %} + {{ param|upper }} +{% endmacro %} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-fromed-macro/test.expected.jinja b/src/test/resources/eager/reconstructs-fromed-macro/test.expected.jinja new file mode 100644 index 000000000..c7213b008 --- /dev/null +++ b/src/test/resources/eager/reconstructs-fromed-macro/test.expected.jinja @@ -0,0 +1,6 @@ +{% set deferred_import_resource_path = 'eager/reconstructs-fromed-macro/has-macro.jinja' %}\ +{% macro to_upper(param) %} + {{ filter:upper.filter(param, ____int3rpr3t3r____) }} +{% endmacro %}\ +{% set deferred_import_resource_path = null %}\ +{{ to_upper(deferred) }} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-fromed-macro/test.jinja b/src/test/resources/eager/reconstructs-fromed-macro/test.jinja new file mode 100644 index 000000000..6c934e61a --- /dev/null +++ b/src/test/resources/eager/reconstructs-fromed-macro/test.jinja @@ -0,0 +1,3 @@ +{% from './has-macro.jinja' import upper as to_upper %} + +{{ to_upper(deferred) }} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-map-node/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-map-node/test.expected.expected.jinja new file mode 100644 index 000000000..aea750573 --- /dev/null +++ b/src/test/resources/eager/reconstructs-map-node/test.expected.expected.jinja @@ -0,0 +1,8 @@ +foo ff +bar bb +['resolved', 'resolved'] + + +foo +bar +['resolved', 'resolved'] diff --git a/src/test/resources/eager/reconstructs-map-node/test.expected.jinja b/src/test/resources/eager/reconstructs-map-node/test.expected.jinja new file mode 100644 index 000000000..12eeae891 --- /dev/null +++ b/src/test/resources/eager/reconstructs-map-node/test.expected.jinja @@ -0,0 +1,16 @@ +{% if deferred %} +{% set foo = [fn:map_entry('foo', 'ff'), fn:map_entry('bar', 'bb')] %} +{% endif %} +{% set my_list = [] %}\ +{% for key, val in foo %}\ +{% do my_list.append(deferred) %} +{{ key ~ ' ' ~ val }}\ +{% endfor %} +{{ my_list }} + +{% set my_list = [] %}\ +{% for __ignored__ in [0] %}\ +{% do my_list.append(deferred) %} +foo{% do my_list.append(deferred) %} +bar{% endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/reconstructs-map-node/test.jinja b/src/test/resources/eager/reconstructs-map-node/test.jinja new file mode 100644 index 000000000..4c95bd29b --- /dev/null +++ b/src/test/resources/eager/reconstructs-map-node/test.jinja @@ -0,0 +1,15 @@ +{% set my_list = [] %} +{% if deferred %} +{% set foo = {'foo': 'ff', 'bar': 'bb'}.items() %} +{% endif %} +{% for key, val in foo -%} +{% do my_list.append(deferred) %} +{{ key ~ ' ' ~ val }} +{%- endfor %} +{{ my_list }} +{% set my_list = [] %} +{% for i in {'foo': 'ff', 'bar': 'bb'}.items() -%} +{% do my_list.append(deferred) %} +{{ i.key }} +{%- endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.expected.jinja new file mode 100644 index 000000000..2ab19ae60 --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.expected.jinja @@ -0,0 +1 @@ +resolved diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.jinja new file mode 100644 index 000000000..cbc662993 --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.expected.jinja @@ -0,0 +1,6 @@ +{% set ns = namespace({'enabled': false} ) %}\ +{% set __macro_setit_1985651457_temp_variable_0__ %}\ +{% set ns.enabled = deferred %}\ +{% endset %}\ +{{ __macro_setit_1985651457_temp_variable_0__ }} +{{ ns.enabled }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.jinja new file mode 100644 index 000000000..1abf8c3a2 --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-across-scope/test.jinja @@ -0,0 +1,4 @@ +{%- set ns = namespace(enabled=false) %} +{%- macro setit() %}{% set ns.enabled = deferred %}{% endmacro %} +{{- setit() -}} +{{ ns.enabled }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.expected.jinja new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.expected.jinja @@ -0,0 +1 @@ +true diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.jinja new file mode 100644 index 000000000..8395227b0 --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.expected.jinja @@ -0,0 +1,13 @@ +{% set ns = namespace({'found': false} ) %}\ +{% for __ignored__ in [0] %}\ +{% if 1 == deferred %}\ +{% set ns.found = true %}\ +{% endif %}\ +{% if 2 == deferred %}\ +{% set ns.found = true %}\ +{% endif %}\ +{% if 3 == deferred %}\ +{% set ns.found = true %}\ +{% endif %}\ +{% endfor %} +{{ ns.found }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.jinja new file mode 100644 index 000000000..2039fc59f --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-in-for-loop/test.jinja @@ -0,0 +1,5 @@ +{%- set ns = namespace(found=false) %} +{%- for item in [1, 2, 3] %} +{%- if item == deferred %}{% set ns.found = true %}{% endif %} +{%- endfor %} +{{ ns.found }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.expected.jinja new file mode 100644 index 000000000..337506cd0 --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.expected.jinja @@ -0,0 +1,2 @@ +namespace({'a': 'aa', 'b': 'b resolved'} ) +namespace({'c': 'cc', 'd': 'd resolved'} ) diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.jinja new file mode 100644 index 000000000..72b11121d --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.expected.jinja @@ -0,0 +1,11 @@ +{% set ns1 = namespace({'a': 'aa'} ) %}\ +{% set ns1.b = 'b ' ~ deferred %} + + +{% set ns2 = namespace({'c': 'cc'} ) %}\ +{% set ns2.d %}\ +d {{ deferred }}\ +{% endset %} + +{{ ns1 }} +{{ ns2 }} diff --git a/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.jinja b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.jinja new file mode 100644 index 000000000..b380d12fa --- /dev/null +++ b/src/test/resources/eager/reconstructs-namespace-for-set-tags-using-period/test.jinja @@ -0,0 +1,10 @@ +{% set ns1 = namespace({'a': 'aa'}) %} +{% set ns1.b = 'b ' ~ deferred %} + +{% set ns2 = namespace({'c': 'cc'}) %} +{% set ns2.d -%} +d {{ deferred }} +{%- endset %} + +{{ ns1 }} +{{ ns2 }} diff --git a/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.expected.jinja new file mode 100644 index 000000000..111d01463 --- /dev/null +++ b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.expected.jinja @@ -0,0 +1,11 @@ +map.foo: Foo is I am foo. +map.bar: Bar is I am bar. + + + +a + + + + +{'b': 'b'} diff --git a/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.jinja b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.jinja new file mode 100644 index 000000000..6fb1f5520 --- /dev/null +++ b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.expected.jinja @@ -0,0 +1,30 @@ +{% set i_am = 'I am' %}\ +{% set bar = '{{ i_am }} bar' %}\ +{% set foo = '{{ i_am }} foo' %}\ +{% set map_with_vals = {'foo': 'Foo is {{ foo }}\ +.'} %}\ +{% if deferred %} +{% do map_with_vals.put('bar', 'Bar is {{ bar }}\ +.') %} +{% endif %} +map.foo: {{ map_with_vals.foo }} +map.bar: {{ map_with_vals.bar }} + +{% set a = 'a' %}\ +{% set aa = '{{ a }}\ +' %}\ +{% if deferred %} +{% set aaa = '{{ aa }}\ +' %} +{{ aa }} +{% endif %} + +{% set b = 'b' %}\ +{% set bb = '{{ b }}\ +' %}\ +{% if deferred %} +{% set bbb = {'b': '{{ bb }}\ +'} %} +{'b': '{{ bb }}\ +'} +{% endif %} \ No newline at end of file diff --git a/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.jinja b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.jinja new file mode 100644 index 000000000..93d747df9 --- /dev/null +++ b/src/test/resources/eager/reconstructs-nested-value-in-string-representation/test.jinja @@ -0,0 +1,24 @@ +{% set i_am = 'I am' %} +{% set foo = '{{ i_am }} foo' %} +{% set bar = '{{ i_am }} bar' %} +{% set map_with_vals = {'foo': 'Foo is {{ foo }}.'} %} +{% if deferred %} +{% do map_with_vals.put('bar', 'Bar is {{ bar }}.') %} +{% endif %} +map.foo: {{ map_with_vals.foo }} +map.bar: {{ map_with_vals.bar }} + +{%- set a = 'a' %} +{% set aa = '{{ a }}' %} +{% if deferred %} +{% set aaa = '{{ aa }}' %} +{{ aaa }} +{% endif -%} + +{%- set b = 'b' %} +{% set bb = '{{ b }}' %} +{% if deferred %} +{% set bbb = {'b': '{{ bb }}'} %} +{{ bbb }} +{% endif -%} + diff --git a/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.expected.jinja b/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.expected.jinja new file mode 100644 index 000000000..07c749944 --- /dev/null +++ b/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.expected.jinja @@ -0,0 +1,12 @@ +{% if deferred %} +{% macro foo(var) %} +{% set second_list = [] %} +{% set second_list = [] %}\ +{% do second_list.append(var) %} +{{ caller() }} +{% endmacro %}\ +{% call foo(deferred) %} +[] +{{ second_list }} +{% endcall %} +{% endif %} diff --git a/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.jinja b/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.jinja new file mode 100644 index 000000000..744281882 --- /dev/null +++ b/src/test/resources/eager/reconstructs-null-variables-in-deferred-caller/test.jinja @@ -0,0 +1,13 @@ +{% set first_list = [] %} +{% macro foo(var) %} +{% set second_list = [] %} +{% do second_list.append(var) %} +{{ caller() }} +{% endmacro %} + +{% if deferred %} +{% call foo(deferred) %} +{{ first_list }} +{{ second_list }} +{% endcall %} +{% endif %} diff --git a/src/test/resources/eager/reconstructs-types-properly/test.expected.jinja b/src/test/resources/eager/reconstructs-types-properly/test.expected.jinja new file mode 100644 index 000000000..0d978cddd --- /dev/null +++ b/src/test/resources/eager/reconstructs-types-properly/test.expected.jinja @@ -0,0 +1 @@ +{{ {'bool': 'true', 'num': '1'} ~ deferred }} diff --git a/src/test/resources/eager/reconstructs-types-properly/test.jinja b/src/test/resources/eager/reconstructs-types-properly/test.jinja new file mode 100644 index 000000000..2c3b71565 --- /dev/null +++ b/src/test/resources/eager/reconstructs-types-properly/test.jinja @@ -0,0 +1,2 @@ +{% set foo = {'bool': 'true', 'num': '1'} %} +{{ foo ~ deferred }} diff --git a/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.expected.jinja new file mode 100644 index 000000000..2d6f0822a --- /dev/null +++ b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.expected.jinja @@ -0,0 +1,4 @@ +resolved 1 + + +resolved resolved diff --git a/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.jinja b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.jinja new file mode 100644 index 000000000..e83a98c98 --- /dev/null +++ b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.expected.jinja @@ -0,0 +1,27 @@ +{% do %}\ +{% set __temp_meta_current_path_528180375__,current_path = current_path,'eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja' %}\ +{% set __temp_meta_import_alias_1081745881__ = {} %}\ +{% for __ignored__ in [0] %}\ +{% set value = deferred %}\ +{% do __temp_meta_import_alias_1081745881__.update({'value': value}) %} + +{% do __temp_meta_import_alias_1081745881__.update({'import_resource_path': 'eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja','value': value}) %}\ +{% endfor %}\ +{% set macros = __temp_meta_import_alias_1081745881__ %}\ +{% set current_path,__temp_meta_current_path_528180375__ = __temp_meta_current_path_528180375__,null %}\ +{% enddo %} + +{% set deferred_import_resource_path = 'eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja' %}\ +{% macro macros.getValueAnd(other) %}\ +{% set value = macros.value %} +{{ value ~ ' ' ~ other }} +{% endmacro %}\ +{% set deferred_import_resource_path = null %}\ +{{ macros.getValueAnd(1) }} +{% set deferred_import_resource_path = 'eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja' %}\ +{% macro macros.getValueAnd(other) %}\ +{% set value = macros.value %} +{{ value ~ ' ' ~ other }} +{% endmacro %}\ +{% set deferred_import_resource_path = null %}\ +{{ macros.getValueAnd(deferred) }} diff --git a/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.jinja b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.jinja new file mode 100644 index 000000000..d5c0b0b63 --- /dev/null +++ b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/test.jinja @@ -0,0 +1,4 @@ +{% import './uses-deferred-value-in-macro.jinja' as macros %} + +{{ macros.getValueAnd(1) }} +{{ macros.getValueAnd(deferred) }} diff --git a/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja new file mode 100644 index 000000000..0c19fc60a --- /dev/null +++ b/src/test/resources/eager/reconstructs-value-used-in-deferred-imported-macro/uses-deferred-value-in-macro.jinja @@ -0,0 +1,4 @@ +{% set value = deferred %} +{% macro getValueAnd(other) %} +{{ value ~ ' ' ~ other }} +{% endmacro %} diff --git a/src/test/resources/eager/reconstructs-with-multiple-loops/test.expected.jinja b/src/test/resources/eager/reconstructs-with-multiple-loops/test.expected.jinja new file mode 100644 index 000000000..6f81e2636 --- /dev/null +++ b/src/test/resources/eager/reconstructs-with-multiple-loops/test.expected.jinja @@ -0,0 +1,25 @@ +[][] +[0][0] +{% set alpha = [0] %}\ +{% set beta = [0] %}\ +{% for i in deferred %} + {% if deferred %} + {% for j in deferred %} + {% if deferred %} + {% do alpha.append(1) %} + {% else %} + {% do alpha.append(2) %} + {% endif %} + {% endfor %} + {% else %} + {% for j in deferred %} + {% do beta.append(1) %} + {% if deferred %} + {% do alpha.append(3) %} + {% else %} + {% do alpha.append(4) %} + {% endif %} + {% endfor %} + {% endif %} +{% endfor %} +{{ alpha ~ beta }} diff --git a/src/test/resources/eager/reconstructs-with-multiple-loops/test.jinja b/src/test/resources/eager/reconstructs-with-multiple-loops/test.jinja new file mode 100644 index 000000000..f0986d480 --- /dev/null +++ b/src/test/resources/eager/reconstructs-with-multiple-loops/test.jinja @@ -0,0 +1,26 @@ +{% set alpha, beta = [], [] %} +{{ alpha ~ beta }} +{%- do alpha.append(0) %} +{%- do beta.append(0) %} +{{ alpha ~ beta }} +{% for i in deferred %} + {% if deferred %} + {% for j in deferred %} + {% if deferred %} + {% do alpha.append(1) %} + {% else %} + {% do alpha.append(2) %} + {% endif %} + {% endfor %} + {% else %} + {% for j in deferred %} + {% do beta.append(1) %} + {% if deferred %} + {% do alpha.append(3) %} + {% else %} + {% do alpha.append(4) %} + {% endif %} + {% endfor %} + {% endif %} +{% endfor %} +{{ alpha ~ beta }} diff --git a/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.expected.jinja b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.expected.jinja new file mode 100644 index 000000000..ac18b1763 --- /dev/null +++ b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.expected.jinja @@ -0,0 +1,2 @@ +a +{{ foo }} diff --git a/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.jinja b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.jinja new file mode 100644 index 000000000..c64e0b1ae --- /dev/null +++ b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.expected.jinja @@ -0,0 +1,5 @@ +{% set foo = 'a' %}\ +{% do deferred.append('{{ foo }}\ +') %} +{{ deferred[0] }} +{% print deferred[0] %} diff --git a/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.jinja b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.jinja new file mode 100644 index 000000000..49db98681 --- /dev/null +++ b/src/test/resources/eager/reconstructs-words-from-inside-nested-expressions/test.jinja @@ -0,0 +1,5 @@ +{% set foo = 'a' %} +{% set bar = 'b' %} +{% do deferred.append('{{ foo }}') %} +{{ deferred[0] }} +{% print deferred[0] %} diff --git a/src/test/resources/eager/reverts-modification-with-deferred-loop/test.expected.jinja b/src/test/resources/eager/reverts-modification-with-deferred-loop/test.expected.jinja new file mode 100644 index 000000000..57568c5f2 --- /dev/null +++ b/src/test/resources/eager/reverts-modification-with-deferred-loop/test.expected.jinja @@ -0,0 +1,15 @@ +{% set my_list = [] %}\ +{% for __ignored__ in [0] %} +{% for j in deferred %} +{% if loop.first %} +{% do my_list.append(1) %} +{% endif %} +{% endfor %} + +{% for j in deferred %} +{% if loop.first %} +{% do my_list.append(1) %} +{% endif %} +{% endfor %} +{% endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/reverts-modification-with-deferred-loop/test.jinja b/src/test/resources/eager/reverts-modification-with-deferred-loop/test.jinja new file mode 100644 index 000000000..0a7621f5a --- /dev/null +++ b/src/test/resources/eager/reverts-modification-with-deferred-loop/test.jinja @@ -0,0 +1,9 @@ +{% set my_list = [] %} +{% for i in range(2) %} +{% for j in deferred %} +{% if loop.first %} +{% do my_list.append(1) %} +{% endif %} +{% endfor %} +{% endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/reverts-simple/test.expected.jinja b/src/test/resources/eager/reverts-simple/test.expected.jinja new file mode 100644 index 000000000..1e69abbd1 --- /dev/null +++ b/src/test/resources/eager/reverts-simple/test.expected.jinja @@ -0,0 +1,10 @@ +{% for __ignored__ in [0] %} + {% set my_list = [0, 1] %}\ +{% if deferred %} + {% do my_list.append(2) %} + {% endif %} + {% do my_list.append(3) %} + +{% do my_list.append(4) %} +{{ my_list }} +{% endfor %} diff --git a/src/test/resources/eager/reverts-simple/test.jinja b/src/test/resources/eager/reverts-simple/test.jinja new file mode 100644 index 000000000..bf32e3e30 --- /dev/null +++ b/src/test/resources/eager/reverts-simple/test.jinja @@ -0,0 +1,15 @@ +{% set foo = 1 %} +{%- for i in range(1) %} +{%- set foo = null %} +{%- set my_list = [] %} +{%- do my_list.append(0) %} +{%- if 5 < 6 %} + {%- do my_list.append(1) %} + {% if deferred %} + {% do my_list.append(2) %} + {% endif %} + {% do my_list.append(3) %} +{% endif %} +{% do my_list.append(4) %} +{{ my_list }} +{% endfor %} diff --git a/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.expected.jinja b/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.expected.jinja new file mode 100644 index 000000000..715787f18 --- /dev/null +++ b/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.expected.jinja @@ -0,0 +1,4 @@ +{% for i in deferred %} +0 +1 +{% endfor %} diff --git a/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.jinja b/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.jinja new file mode 100644 index 000000000..4b23b2a3d --- /dev/null +++ b/src/test/resources/eager/runs-for-loop-inside-deferred-for-loop/test.jinja @@ -0,0 +1,5 @@ +{% for i in deferred -%} +{% for j in [0, 1] %} +{{ j }} +{%- endfor %} +{% endfor %} diff --git a/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.expected.jinja b/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.expected.jinja new file mode 100644 index 000000000..d7ce85653 --- /dev/null +++ b/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.expected.jinja @@ -0,0 +1,12 @@ +{% set holder = {'val': -1} %}\ +{% for i in range(deferred) %} +{% set __macro_modify_615470226_temp_variable_1__ %} +{% do holder.update({'val': holder.val + 1}) %} +{% endset %}\ +{% do __macro_modify_615470226_temp_variable_1__ %} +{% if holder.val >= 1 %} +{{ i }} +{% endif %} +{% endfor %} + +{{ holder.val }} \ No newline at end of file diff --git a/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.jinja b/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.jinja new file mode 100644 index 000000000..9670c58de --- /dev/null +++ b/src/test/resources/eager/runs-macro-function-in-deferred-execution-mode/test.jinja @@ -0,0 +1,12 @@ +{% macro modify(val) %} +{% do holder.update({'val': holder.val + 1}) %} +{% endmacro %} +{% set holder = {'val': -1} %} +{% for i in range(deferred) %} +{% do modify(1) %} +{% if holder.val >= 1 %} +{{ i }} +{% endif %} +{% endfor %} + +{{ holder.val }} diff --git a/src/test/resources/eager/scopes-resetting-bindings/test.expected.jinja b/src/test/resources/eager/scopes-resetting-bindings/test.expected.jinja new file mode 100644 index 000000000..097e54f2d --- /dev/null +++ b/src/test/resources/eager/scopes-resetting-bindings/test.expected.jinja @@ -0,0 +1,9 @@ +{% set my_list = [0] %}\ +{% for i in deferred %} +{% for j in deferred %} +{% if deferred %} +{% do my_list.append(1) %} +{% endif %} +{% endfor %} +{% endfor %} +{{ my_list }} diff --git a/src/test/resources/eager/scopes-resetting-bindings/test.jinja b/src/test/resources/eager/scopes-resetting-bindings/test.jinja new file mode 100644 index 000000000..6ccd119a1 --- /dev/null +++ b/src/test/resources/eager/scopes-resetting-bindings/test.jinja @@ -0,0 +1,10 @@ +{% set my_list = [] %} +{% do my_list.append(0) %} +{% for i in deferred %} +{% for j in deferred %} +{% if deferred %} +{% do my_list.append(1) %} +{% endif %} +{% endfor %} +{% endfor %} +{{ my_list }} \ No newline at end of file diff --git a/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.expected.jinja b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.expected.jinja new file mode 100644 index 000000000..34f0c20f9 --- /dev/null +++ b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.expected.jinja @@ -0,0 +1,2 @@ +1 & 2 +3 & 4 diff --git a/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.jinja b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.jinja new file mode 100644 index 000000000..5de8e1606 --- /dev/null +++ b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.expected.jinja @@ -0,0 +1,8 @@ +1 & 2{% set bar = 2 %}\ +{% set foo = 3 %}\ +{% if deferred %}\ +{% set bar = 4 %}\ +{% else %}\ +{% set foo = 5 %}\ +{% endif %} +{{ foo }} & {{ bar }} diff --git a/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.jinja b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.jinja new file mode 100644 index 000000000..226c21ee5 --- /dev/null +++ b/src/test/resources/eager/sets-multiple-vars-deferred-in-child/test.jinja @@ -0,0 +1,9 @@ +{%- set foo, bar = 1, 2 -%} +{{ foo }} & {{ bar }} +{%- set foo = 3 -%} +{%- if deferred -%} +{%- set bar = 4 -%} +{%- else -%} +{%- set foo = 5 -%} +{%- endif %} +{{ foo }} & {{ bar }} diff --git a/src/test/resources/eager/supplements/deferred-modification.jinja b/src/test/resources/eager/supplements/deferred-modification.jinja new file mode 100644 index 000000000..bb080a0ff --- /dev/null +++ b/src/test/resources/eager/supplements/deferred-modification.jinja @@ -0,0 +1,7 @@ +{% if deferred %} + +{% set foo = [foo, 'a']|join('') %} + +{% endif %} + +{% set foo = [foo, 'b']|join('') %} diff --git a/src/test/resources/eager/supplements/macro-and-set.jinja b/src/test/resources/eager/supplements/macro-and-set.jinja new file mode 100644 index 000000000..12c7475a3 --- /dev/null +++ b/src/test/resources/eager/supplements/macro-and-set.jinja @@ -0,0 +1,5 @@ +{% macro foo() -%} +Hello {{ myname }} +{%- endmacro %} +{% set bar = myname + 19 %} +{{ foo() }} diff --git a/src/test/resources/eager/supplements/set-val.jinja b/src/test/resources/eager/supplements/set-val.jinja new file mode 100644 index 000000000..381648d46 --- /dev/null +++ b/src/test/resources/eager/supplements/set-val.jinja @@ -0,0 +1 @@ +{% set primary_line_height = 42 %} \ No newline at end of file diff --git a/src/test/resources/eager/supplements/simple-with-call.jinja b/src/test/resources/eager/supplements/simple-with-call.jinja new file mode 100644 index 000000000..243ecf68a --- /dev/null +++ b/src/test/resources/eager/supplements/simple-with-call.jinja @@ -0,0 +1,5 @@ +{% macro getPath() -%} + Hello {{ myname }} +{%- endmacro %} + +{{ getPath() }} diff --git a/src/test/resources/eager/uses-unique-macro-names/macro-with-filter.jinja b/src/test/resources/eager/uses-unique-macro-names/macro-with-filter.jinja new file mode 100644 index 000000000..fd1869569 --- /dev/null +++ b/src/test/resources/eager/uses-unique-macro-names/macro-with-filter.jinja @@ -0,0 +1,4 @@ +{% macro foo() -%} +Hello {{ myname }} +{%- endmacro %} +{% set b = foo()|upper %} diff --git a/src/test/resources/eager/uses-unique-macro-names/test.expected.expected.jinja b/src/test/resources/eager/uses-unique-macro-names/test.expected.expected.jinja new file mode 100644 index 000000000..b82681932 --- /dev/null +++ b/src/test/resources/eager/uses-unique-macro-names/test.expected.expected.jinja @@ -0,0 +1,3 @@ +GOODBYE RESOLVED + +HELLO RESOLVED diff --git a/src/test/resources/eager/uses-unique-macro-names/test.expected.jinja b/src/test/resources/eager/uses-unique-macro-names/test.expected.jinja new file mode 100644 index 000000000..54c40b809 --- /dev/null +++ b/src/test/resources/eager/uses-unique-macro-names/test.expected.jinja @@ -0,0 +1,16 @@ +{% set myname = deferred %} + +{% set __macro_foo_97643642_temp_variable_0__ %} +Goodbye {{ myname }} +{% endset %}\ +{% set a = filter:upper.filter(__macro_foo_97643642_temp_variable_0__, ____int3rpr3t3r____) %} +{% do %}\ +{% set __temp_meta_current_path_203114534__,current_path = current_path,'eager/uses-unique-macro-names/macro-with-filter.jinja' %} +{% set __macro_foo_1717337666_temp_variable_0__ %}\ +Hello {{ myname }}\ +{% endset %}\ +{% set b = filter:upper.filter(__macro_foo_1717337666_temp_variable_0__, ____int3rpr3t3r____) %} +{% set current_path,__temp_meta_current_path_203114534__ = __temp_meta_current_path_203114534__,null %}\ +{% enddo %} +{{ a }} +{{ b }} \ No newline at end of file diff --git a/src/test/resources/eager/uses-unique-macro-names/test.jinja b/src/test/resources/eager/uses-unique-macro-names/test.jinja new file mode 100644 index 000000000..c5c123857 --- /dev/null +++ b/src/test/resources/eager/uses-unique-macro-names/test.jinja @@ -0,0 +1,8 @@ +{% set myname = deferred %} +{% macro foo() %} +Goodbye {{ myname }} +{% endmacro %} +{% set a = foo()|upper %} +{% import './macro-with-filter.jinja' %} +{{ a }} +{{ b }} diff --git a/src/test/resources/eager/wraps-certain-output-in-raw/test.expected.jinja b/src/test/resources/eager/wraps-certain-output-in-raw/test.expected.jinja new file mode 100644 index 000000000..9e748c22c --- /dev/null +++ b/src/test/resources/eager/wraps-certain-output-in-raw/test.expected.jinja @@ -0,0 +1,20 @@ +{% raw %}\ +{{ bar }}\ +{% endraw %} +{% raw %}\ +{{ bar }}\ +{% endraw %} +{% raw %}\ +{% print foo %}\ +{% endraw %} +{% raw %}\ +{% print foo %}\ +{% endraw %} +{% raw %}\ +{% baz %}\ +{% endraw %} +{% raw %}\ +{% baz %}\ +{% endraw %} +No raw +No raw diff --git a/src/test/resources/eager/wraps-certain-output-in-raw/test.jinja b/src/test/resources/eager/wraps-certain-output-in-raw/test.jinja new file mode 100644 index 000000000..202ed9743 --- /dev/null +++ b/src/test/resources/eager/wraps-certain-output-in-raw/test.jinja @@ -0,0 +1,9 @@ +{% set foo = '{{ bar }}' %} +{% set bar = '{% print foo %}' %} +{% set foobar = '{% baz %}' %} +{% set baz = 'No raw' %} +{% set vars = [foo, bar, foobar, baz] %} +{%- for var in vars %} +{{ var }} +{% print var %} +{%- endfor -%} diff --git a/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir1/macro.jinja b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir1/macro.jinja new file mode 100644 index 000000000..45fb53d08 --- /dev/null +++ b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir1/macro.jinja @@ -0,0 +1,4 @@ +{% macro foo_importer() -%} + {%- include "../dir2/included.jinja" -%} + {%- print foo -%} +{%- endmacro %} \ No newline at end of file diff --git a/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir2/included.jinja b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir2/included.jinja new file mode 100644 index 000000000..bde4ef606 --- /dev/null +++ b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/dir2/included.jinja @@ -0,0 +1,2 @@ +{% set foo = deferred %} +{{ foo }} \ No newline at end of file diff --git a/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.expected.jinja b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.expected.jinja new file mode 100644 index 000000000..1627e6490 --- /dev/null +++ b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.expected.jinja @@ -0,0 +1,10 @@ +Starting current_path: eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja +Intermediate current_path: eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja +{% for __ignored__ in [0] %}\ +{% set __temp_meta_current_path_629250870__,current_path = 'eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja', 'eager/wraps-macro-that-would-change-current-path-in-child-scope/dir2/included.jinja' %}\ +{% set foo = deferred %} +{{ foo }}\ +{% set current_path,__temp_meta_current_path_629250870__ = 'eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja', null %}\ +{% print foo %}\ +{% endfor %} +Ending current_path: eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja diff --git a/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja new file mode 100644 index 000000000..e1f64358a --- /dev/null +++ b/src/test/resources/eager/wraps-macro-that-would-change-current-path-in-child-scope/test.jinja @@ -0,0 +1,5 @@ +Starting current_path: {{ current_path }} +{% from "./dir1/macro.jinja" import foo_importer -%} +Intermediate current_path: {{ current_path }} +{{ foo_importer() }} +Ending current_path: {{ current_path }} diff --git a/src/test/resources/filter/blog.html b/src/test/resources/filter/blog.html new file mode 100644 index 000000000..86cf073d7 --- /dev/null +++ b/src/test/resources/filter/blog.html @@ -0,0 +1,2597 @@ + + + Blog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
    + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + +
    + + + +
    + +
    + + + +
    + +
    + +
    + + + +
    + + + + + +

    Stratsys Blog

    + + + + + + + + + + +
    +
    Why start from a blank sheet? Our experience within both public and private sector has provided a range of out of the lorem ipsum...
    +
    + + + + + + + + + + + + + + + + + +
    + +
    + +
    + + + +
    + +
    +
    +
    +
    +
    + + +
    + + + + + +
    + + + + +
    +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    +
    +
    +
    + +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/filter/render/base.jinja b/src/test/resources/filter/render/base.jinja new file mode 100644 index 000000000..d9488b984 --- /dev/null +++ b/src/test/resources/filter/render/base.jinja @@ -0,0 +1,6 @@ +Body is: {% block body %} +Base body +{% endblock %} +Footer is: {% block footer %} +Base footer +{% endblock %} diff --git a/src/test/resources/filter/slice-filter-big.jinja b/src/test/resources/filter/slice-filter-big.jinja new file mode 100644 index 000000000..6710c8fd7 --- /dev/null +++ b/src/test/resources/filter/slice-filter-big.jinja @@ -0,0 +1,6 @@ +{%- for column in items|slice(999999999, 'hello') %} + {{ loop.index }} + {%- for item in column %} + {{ item }} + {%- endfor %} +{%- endfor %} diff --git a/src/test/resources/filter/slice-filter-empty.jinja b/src/test/resources/filter/slice-filter-empty.jinja new file mode 100644 index 000000000..ebaa9b033 --- /dev/null +++ b/src/test/resources/filter/slice-filter-empty.jinja @@ -0,0 +1,6 @@ +{%- for column in items|slice(2, 'hello') %} + {{ loop.index }} + {%- for item in column %} + {{ item }} + {%- endfor %} +{%- endfor %} diff --git a/src/test/resources/filter/slice-filter-negative.jinja b/src/test/resources/filter/slice-filter-negative.jinja new file mode 100644 index 000000000..b2988a53b --- /dev/null +++ b/src/test/resources/filter/slice-filter-negative.jinja @@ -0,0 +1,6 @@ +{%- for column in items|slice(-1, 'hello') %} + {{ loop.index }} + {%- for item in column %} + {{ item }} + {%- endfor %} +{%- endfor %} diff --git a/src/test/resources/filter/slice-filter-replacement.jinja b/src/test/resources/filter/slice-filter-replacement.jinja new file mode 100644 index 000000000..ebaa9b033 --- /dev/null +++ b/src/test/resources/filter/slice-filter-replacement.jinja @@ -0,0 +1,6 @@ +{%- for column in items|slice(2, 'hello') %} + {{ loop.index }} + {%- for item in column %} + {{ item }} + {%- endfor %} +{%- endfor %} diff --git a/src/test/resources/filter/slice-filter-zero.jinja b/src/test/resources/filter/slice-filter-zero.jinja new file mode 100644 index 000000000..1cfb278ae --- /dev/null +++ b/src/test/resources/filter/slice-filter-zero.jinja @@ -0,0 +1,6 @@ +{%- for column in items|slice(0, 'hello') %} + {{ loop.index }} + {%- for item in column %} + {{ item }} + {%- endfor %} +{%- endfor %} diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 859cb540e..5a6a22834 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -1,4 +1,4 @@ - + diff --git a/src/test/resources/parse/tokenizer/comment-without-whitespace.jinja b/src/test/resources/parse/tokenizer/comment-without-whitespace.jinja new file mode 100644 index 000000000..cede8a774 --- /dev/null +++ b/src/test/resources/parse/tokenizer/comment-without-whitespace.jinja @@ -0,0 +1 @@ +${#array[@]} \ No newline at end of file diff --git a/src/test/resources/parse/tokenizer/positions.jinja b/src/test/resources/parse/tokenizer/positions.jinja new file mode 100644 index 000000000..e8fb2369c --- /dev/null +++ b/src/test/resources/parse/tokenizer/positions.jinja @@ -0,0 +1,7 @@ +{% if foo %} + {% if bar %} + {{ 1 }} + {# this is a comment #} + {% endif %} + {% raw %}hello world.{% endraw %} +{% endif diff --git a/src/test/resources/parse/tokenizer/tag-with-all-escaped-quotes.jinja b/src/test/resources/parse/tokenizer/tag-with-all-escaped-quotes.jinja new file mode 100644 index 000000000..5f9a709a9 --- /dev/null +++ b/src/test/resources/parse/tokenizer/tag-with-all-escaped-quotes.jinja @@ -0,0 +1,7 @@ +{# This print tag is invalid, but it should not cause the rest of the template to break #} +{% print \"foo\" %} +Start +{% if true %} +The dog says: "Woof!" +{% endif %} +End diff --git a/src/test/resources/parse/tokenizer/tag-with-trim-chars.jinja b/src/test/resources/parse/tokenizer/tag-with-trim-chars.jinja new file mode 100644 index 000000000..bb1ab0390 --- /dev/null +++ b/src/test/resources/parse/tokenizer/tag-with-trim-chars.jinja @@ -0,0 +1,3 @@ +{% macro echo(what) -%} +echo {{ what }} +{%- endmacro %} \ No newline at end of file diff --git a/src/test/resources/parse/tokenizer/whitespace-comment-tags.jinja b/src/test/resources/parse/tokenizer/whitespace-comment-tags.jinja new file mode 100644 index 000000000..6cb5a61bb --- /dev/null +++ b/src/test/resources/parse/tokenizer/whitespace-comment-tags.jinja @@ -0,0 +1,8 @@ +
    + {# a comment #} + yay + {# another comment + This time, over multiple lines + #} + whoop +
    diff --git a/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.expected.jinja b/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.expected.jinja new file mode 100644 index 000000000..b0fbdbe5b --- /dev/null +++ b/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.expected.jinja @@ -0,0 +1,5 @@ +1st macro +1st caller + +2nd macro +2nd caller \ No newline at end of file diff --git a/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.jinja b/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.jinja new file mode 100644 index 000000000..ff9001fe6 --- /dev/null +++ b/src/test/resources/snippets/does-not-override-call-tag-from-higher-scope.jinja @@ -0,0 +1,14 @@ +{% macro first() -%} +1st macro +{{ caller() }} +{% endmacro %} +{% call first() -%} +1st caller +{% macro second() -%} +2nd macro +{{ caller() }} +{% endmacro %} +{% call second() -%} +2nd caller +{% endcall %} +{% endcall %} diff --git a/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.expected.jinja b/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.expected.jinja new file mode 100644 index 000000000..0c6bd20f4 --- /dev/null +++ b/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.expected.jinja @@ -0,0 +1,3 @@ +bar + +2nd foo \ No newline at end of file diff --git a/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.jinja b/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.jinja new file mode 100644 index 000000000..8f4d1658d --- /dev/null +++ b/src/test/resources/snippets/does-not-override-macro-functions-from-higher-scope.jinja @@ -0,0 +1,13 @@ +{% macro foo() %} +1st foo +{% endmacro %} +{% macro bar() %} +bar +{{ foo() }} +{% endmacro %} +{% for i in range(1) %} +{% macro foo() %} +2nd foo +{% endmacro %} +{{ bar() }} +{% endfor %} diff --git a/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.expected.jinja b/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.expected.jinja new file mode 100644 index 000000000..e13c5bfaa --- /dev/null +++ b/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.expected.jinja @@ -0,0 +1,3 @@ +1 +2 +1 diff --git a/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.jinja b/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.jinja new file mode 100644 index 000000000..5947a7144 --- /dev/null +++ b/src/test/resources/snippets/uses-lower-scope-value-in-macro-evaluation.jinja @@ -0,0 +1,10 @@ +{% set val = 1 %} +{% macro foo() -%} +{{ val }} +{%- endmacro %} +{% for i in range(1) %} +{{ foo() }} +{%- set val = 2 %} +{{ foo() }} +{%- endfor %} +{{ val }} diff --git a/src/test/resources/tags/calltag/multiple.jinja b/src/test/resources/tags/calltag/multiple.jinja new file mode 100644 index 000000000..2bbd3beae --- /dev/null +++ b/src/test/resources/tags/calltag/multiple.jinja @@ -0,0 +1,4 @@ +{% macro test1() %}1{{ caller() }}{% endmacro %} +{% macro test2() %}2{{ caller() }}{% endmacro %} +{% macro test3() %}3{{ caller() }}{% endmacro %} +{% call test1() %}{% call test2() %}{% call test3() %}{% endcall %}{% endcall %}{% endcall %} \ No newline at end of file diff --git a/src/test/resources/tags/eager/extendstag/base-deferred.html b/src/test/resources/tags/eager/extendstag/base-deferred.html new file mode 100644 index 000000000..0df99d7ac --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/base-deferred.html @@ -0,0 +1,9 @@ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/base.html b/src/test/resources/tags/eager/extendstag/base.html new file mode 100644 index 000000000..425d4e227 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/base.html @@ -0,0 +1,9 @@ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.expected.jinja new file mode 100644 index 000000000..f34865101 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.expected.jinja @@ -0,0 +1,8 @@ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.jinja new file mode 100644 index 000000000..1e804dbee --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.expected.jinja @@ -0,0 +1,11 @@ +{% set current_path = '../eager/extendstag/base.html' %}\ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.jinja b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.jinja new file mode 100644 index 000000000..022204cd9 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-block-in-extends-child.jinja @@ -0,0 +1,6 @@ +{% extends "../eager/extendstag/base.html" %} + +{%- block sidebar -%} +

    Table Of Contents

    +{{ deferred }} +{%- endblock -%} diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.expected.jinja new file mode 100644 index 000000000..a4e73e2b1 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.expected.jinja @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.jinja new file mode 100644 index 000000000..41b046b1c --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.expected.jinja @@ -0,0 +1,13 @@ +{% set current_path = '../eager/extendstag/base-deferred.html' %}\ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.jinja new file mode 100644 index 000000000..cc301ad57 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred-nested-interp.jinja @@ -0,0 +1,6 @@ +{% extends "../eager/extendstag/base-deferred.html" %} + +{%- block sidebar -%} +

    Table Of Contents

    +{{ super() }} +{%- endblock -%} diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.expected.jinja new file mode 100644 index 000000000..a4e73e2b1 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.expected.jinja @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.jinja new file mode 100644 index 000000000..41b046b1c --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.expected.jinja @@ -0,0 +1,13 @@ +{% set current_path = '../eager/extendstag/base-deferred.html' %}\ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.jinja b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.jinja new file mode 100644 index 000000000..cc301ad57 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/defers-super-block-with-deferred.jinja @@ -0,0 +1,6 @@ +{% extends "../eager/extendstag/base-deferred.html" %} + +{%- block sidebar -%} +

    Table Of Contents

    +{{ super() }} +{%- endblock -%} diff --git a/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.expected.jinja b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.expected.jinja new file mode 100644 index 000000000..3abce19c6 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.expected.jinja @@ -0,0 +1,8 @@ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.jinja b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.jinja new file mode 100644 index 000000000..b01cdf9bf --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.expected.jinja @@ -0,0 +1,18 @@ +{# Start Label: ignored_output_from_extends #}{% do %} +{% if deferred %} +{% set foo = 'yes' %} +{% else %} +{% set foo = 'no' %} +{% endif %}\ +{% enddo %}\ +{# End Label: ignored_output_from_extends #}{% set current_path = '../eager/extendstag/base.html' %}\ + + + + + diff --git a/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.jinja b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.jinja new file mode 100644 index 000000000..0d6bf25cf --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/reconstructs-deferred-outside-block.jinja @@ -0,0 +1,11 @@ +{% extends "../eager/extendstag/base.html" %} +{% if deferred %} +{% set foo = 'yes' %} +{% else %} +{% set foo = 'no' %} +{% endif %} + +{%- block sidebar -%} +

    Table Of Contents

    +

    {{ foo }}.

    +{%- endblock -%} diff --git a/src/test/resources/tags/eager/extendstag/throws-when-deferred-extends-tag.jinja b/src/test/resources/tags/eager/extendstag/throws-when-deferred-extends-tag.jinja new file mode 100644 index 000000000..e55301697 --- /dev/null +++ b/src/test/resources/tags/eager/extendstag/throws-when-deferred-extends-tag.jinja @@ -0,0 +1,9 @@ +{% if deferred %} +{% extends "../eager/extendstag/base.html" %} +{% else %} +{% extends "../eager/extendstag/base.html" %} +{% endif %} + +{%- block sidebar -%} +

    Table Of Contents

    +{%- endblock -%} \ No newline at end of file diff --git a/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.expected.jinja b/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.expected.jinja new file mode 100644 index 000000000..a2652a9c0 --- /dev/null +++ b/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.expected.jinja @@ -0,0 +1,6 @@ +{% for item, item2 in deferred %} + e + {{ item }} + {{ item2 }} + g +{% endfor %} diff --git a/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.jinja b/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.jinja new file mode 100644 index 000000000..68cf50bad --- /dev/null +++ b/src/test/resources/tags/eager/fortag/handles-multiple-loop-vars.jinja @@ -0,0 +1,6 @@ +{% for item, item2 in deferred %} + {{ 'e' }} + {{ item }} + {{ item2 }} + g +{% endfor %} diff --git a/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.expected.jinja b/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.expected.jinja new file mode 100644 index 000000000..fd102d03e --- /dev/null +++ b/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.expected.jinja @@ -0,0 +1,16 @@ +{% for __ignored__ in [0] %} +{% for bar in deferred %} +pastrami sandwich. {{ bar }}\ +! +{% endfor %} + +{% for bar in deferred %} +pastrami salad. {{ bar }}\ +! +{% endfor %} + +{% for bar in deferred %} +pastrami smoothie. {{ bar }}\ +! +{% endfor %} +{% endfor %} diff --git a/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.jinja b/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.jinja new file mode 100644 index 000000000..a25d7d697 --- /dev/null +++ b/src/test/resources/tags/eager/fortag/handles-nested-deferred-for-loop.jinja @@ -0,0 +1,5 @@ +{% for item in food_types %} +{% for bar in deferred %} +pastrami {{ item }}. {{ bar }}! +{% endfor %} +{% endfor %} diff --git a/src/test/resources/tags/eager/fortag/registers-eager-token.expected.jinja b/src/test/resources/tags/eager/fortag/registers-eager-token.expected.jinja new file mode 100644 index 000000000..930588bcc --- /dev/null +++ b/src/test/resources/tags/eager/fortag/registers-eager-token.expected.jinja @@ -0,0 +1,3 @@ +{% for item in deferred %} + e +{% endfor %} diff --git a/src/test/resources/tags/eager/fortag/registers-eager-token.jinja b/src/test/resources/tags/eager/fortag/registers-eager-token.jinja new file mode 100644 index 000000000..3b24c44b4 --- /dev/null +++ b/src/test/resources/tags/eager/fortag/registers-eager-token.jinja @@ -0,0 +1,3 @@ +{% for item in deferred %} + {{ 'e' }} +{% endfor %} diff --git a/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.expected.jinja b/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.expected.jinja new file mode 100644 index 000000000..8bd0d6cc5 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.expected.jinja @@ -0,0 +1,7 @@ +{% if 1 == deferred %} +1 is {{ deferred }} +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.jinja b/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.jinja new file mode 100644 index 000000000..e8caa6ecf --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-deferred-in-eager.jinja @@ -0,0 +1,7 @@ +{% if foo == deferred %} +{{ foo }} is {{ deferred }} +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.expected.jinja b/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.expected.jinja new file mode 100644 index 000000000..df6fb86cc --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.expected.jinja @@ -0,0 +1 @@ +Yes {{ deferred }} diff --git a/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.jinja b/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.jinja new file mode 100644 index 000000000..4c2aef6a3 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-deferred-in-regular.jinja @@ -0,0 +1,5 @@ +{% if foo %} +Yes {{ deferred }} +{% else %} +No {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.expected.jinja b/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.expected.jinja new file mode 100644 index 000000000..ea47d3bd2 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.expected.jinja @@ -0,0 +1,6 @@ +{% if false %}\ +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.jinja b/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.jinja new file mode 100644 index 000000000..3ab8d89e0 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/handles-only-deferred-elif.jinja @@ -0,0 +1,7 @@ +{% if false %} +{{ foo }} +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.expected.jinja b/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.expected.jinja new file mode 100644 index 000000000..1f1f15b59 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.expected.jinja @@ -0,0 +1,5 @@ +{% if deferred %} +1 +{% else %} +4 +{% endif %} diff --git a/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.jinja b/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.jinja new file mode 100644 index 000000000..ff6d63222 --- /dev/null +++ b/src/test/resources/tags/eager/iftag/removes-impossible-if-blocks.jinja @@ -0,0 +1,11 @@ +{% if deferred %} +1 +{% elif false %} +2 +{% elif !foo %} +3 +{% elif true %} +4 +{% else %} +5 +{% endif %} diff --git a/src/test/resources/tags/eager/importtag/double-import-macro.jinja b/src/test/resources/tags/eager/importtag/double-import-macro.jinja new file mode 100644 index 000000000..6696dba03 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/double-import-macro.jinja @@ -0,0 +1,5 @@ +{% import 'import-macro.jinja' -%} +{% macro print_path_macro2(var) -%} +{{ var|print_path }} +{{ print_path_macro(var) }} +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/import-macro-and-var.jinja b/src/test/resources/tags/eager/importtag/import-macro-and-var.jinja new file mode 100644 index 000000000..6b2bebe5d --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-macro-and-var.jinja @@ -0,0 +1,5 @@ +{% set var = [] -%} +{% macro adjust(val) -%} +{% do var.append(val ~ deferred) -%} +{{ val }} +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/import-macro-applies-val.jinja b/src/test/resources/tags/eager/importtag/import-macro-applies-val.jinja new file mode 100644 index 000000000..d1b71c97e --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-macro-applies-val.jinja @@ -0,0 +1,4 @@ +{% set val = 5 -%} +{% macro apply(param) -%} +{{ val ~ param }} +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/import-macro.jinja b/src/test/resources/tags/eager/importtag/import-macro.jinja new file mode 100644 index 000000000..6c25a1782 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-macro.jinja @@ -0,0 +1,4 @@ +{% macro print_path_macro(var) %} +{{ var|print_path }} +{{ var }} +{% endmacro %} diff --git a/src/test/resources/tags/eager/importtag/import-tree-a.jinja b/src/test/resources/tags/eager/importtag/import-tree-a.jinja new file mode 100644 index 000000000..16096b64c --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-tree-a.jinja @@ -0,0 +1,2 @@ +{% set something = 'somn' %} +{% set foo_a = a_val %} diff --git a/src/test/resources/tags/eager/importtag/import-tree-b.jinja b/src/test/resources/tags/eager/importtag/import-tree-b.jinja new file mode 100644 index 000000000..454ee0e45 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-tree-b.jinja @@ -0,0 +1,2 @@ +{% import 'import-tree-a.jinja' as a %} +{% set foo_b = b_val + a.foo_a %} diff --git a/src/test/resources/tags/eager/importtag/import-tree-c.jinja b/src/test/resources/tags/eager/importtag/import-tree-c.jinja new file mode 100644 index 000000000..e7096603a --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-tree-c.jinja @@ -0,0 +1,2 @@ +{% import 'import-tree-b.jinja' as b %} +{% set foo_c = c_val + b.foo_b + b.a.foo_a %} diff --git a/src/test/resources/tags/eager/importtag/import-tree-d.jinja b/src/test/resources/tags/eager/importtag/import-tree-d.jinja new file mode 100644 index 000000000..ec951a109 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/import-tree-d.jinja @@ -0,0 +1,5 @@ +{% import 'import-tree-c.jinja' as c %} +{% set foo_d = c.foo_c + c.b.foo_b + c.b.a.foo_a %} +{% import 'import-tree-b.jinja' as b2 %} +{% set resolvable = 12345 %} +{% set bar = foo_d + b2.foo_b %} diff --git a/src/test/resources/tags/eager/importtag/intermediate-b.jinja b/src/test/resources/tags/eager/importtag/intermediate-b.jinja new file mode 100644 index 000000000..f7d4aeedd --- /dev/null +++ b/src/test/resources/tags/eager/importtag/intermediate-b.jinja @@ -0,0 +1,4 @@ +{% import 'printer-b.jinja' as printer %} +{% macro print(x='inter') -%} +{{ printer.print(x) }} +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/layer-one.jinja b/src/test/resources/tags/eager/importtag/layer-one.jinja new file mode 100644 index 000000000..163bd0c3d --- /dev/null +++ b/src/test/resources/tags/eager/importtag/layer-one.jinja @@ -0,0 +1,2 @@ +{% set bar = 'bar val' %} +{% import 'layer-two.jinja' as double_child %} \ No newline at end of file diff --git a/src/test/resources/tags/eager/importtag/layer-two.jinja b/src/test/resources/tags/eager/importtag/layer-two.jinja new file mode 100644 index 000000000..6c9be8f6f --- /dev/null +++ b/src/test/resources/tags/eager/importtag/layer-two.jinja @@ -0,0 +1,2 @@ +{% set foo = 'foo val' %} +{% set foo_d = deferred %} diff --git a/src/test/resources/tags/eager/importtag/macro-a.jinja b/src/test/resources/tags/eager/importtag/macro-a.jinja new file mode 100644 index 000000000..aae0a7b3d --- /dev/null +++ b/src/test/resources/tags/eager/importtag/macro-a.jinja @@ -0,0 +1,3 @@ +{% macro doer() %} +I am doer 'A', {{ deferred }} +{% endmacro %} \ No newline at end of file diff --git a/src/test/resources/tags/eager/importtag/macro-b.jinja b/src/test/resources/tags/eager/importtag/macro-b.jinja new file mode 100644 index 000000000..4e57ba4dd --- /dev/null +++ b/src/test/resources/tags/eager/importtag/macro-b.jinja @@ -0,0 +1,3 @@ +{% macro doer() %} +I am doer 'b', {{ deferred }} +{% endmacro %} \ No newline at end of file diff --git a/src/test/resources/tags/eager/importtag/printer-a.jinja b/src/test/resources/tags/eager/importtag/printer-a.jinja new file mode 100644 index 000000000..26eb429f0 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/printer-a.jinja @@ -0,0 +1,3 @@ +{% macro print(x='A') -%} +A_{{ x }}_A +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/printer-b.jinja b/src/test/resources/tags/eager/importtag/printer-b.jinja new file mode 100644 index 000000000..bc5fad3a6 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/printer-b.jinja @@ -0,0 +1,3 @@ +{% macro print(x='B') -%} +B_{{ x }}_B +{%- endmacro %} diff --git a/src/test/resources/tags/eager/importtag/set-two-variables.jinja b/src/test/resources/tags/eager/importtag/set-two-variables.jinja new file mode 100644 index 000000000..2f69297f9 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/set-two-variables.jinja @@ -0,0 +1,2 @@ +{% set foo = deferred %} +{% set bar = 'bar' %} diff --git a/src/test/resources/tags/eager/importtag/var-a.jinja b/src/test/resources/tags/eager/importtag/var-a.jinja new file mode 100644 index 000000000..945acff03 --- /dev/null +++ b/src/test/resources/tags/eager/importtag/var-a.jinja @@ -0,0 +1 @@ +{% set foo = 'a' %} diff --git a/src/test/resources/tags/eager/importtag/var-b.jinja b/src/test/resources/tags/eager/importtag/var-b.jinja new file mode 100644 index 000000000..2ecfd14ef --- /dev/null +++ b/src/test/resources/tags/eager/importtag/var-b.jinja @@ -0,0 +1 @@ +{% set foo = 'b' %} diff --git a/src/test/resources/tags/eager/importtag/variables.jinja b/src/test/resources/tags/eager/importtag/variables.jinja new file mode 100644 index 000000000..43306d9fe --- /dev/null +++ b/src/test/resources/tags/eager/importtag/variables.jinja @@ -0,0 +1 @@ +{% set foo = {'foo': {bar: 'here'}} %} \ No newline at end of file diff --git a/src/test/resources/tags/eager/includetag/includes-deferred.expected.jinja b/src/test/resources/tags/eager/includetag/includes-deferred.expected.jinja new file mode 100644 index 000000000..606c5930b --- /dev/null +++ b/src/test/resources/tags/eager/includetag/includes-deferred.expected.jinja @@ -0,0 +1,7 @@ +Foo begins as: abc +{% set __temp_meta_current_path_38651297__,current_path = current_path,'sets-to-deferred.jinja' %}\ +{% set foo = deferred %} +foo is now {{ deferred }}\ +. +{% set current_path,__temp_meta_current_path_38651297__ = __temp_meta_current_path_38651297__,null %} +Foo ends as: {{ foo }} diff --git a/src/test/resources/tags/eager/includetag/includes-deferred.jinja b/src/test/resources/tags/eager/includetag/includes-deferred.jinja new file mode 100644 index 000000000..e3542bf83 --- /dev/null +++ b/src/test/resources/tags/eager/includetag/includes-deferred.jinja @@ -0,0 +1,4 @@ +{%- set foo = 'abc' -%} +Foo begins as: {{ foo }} +{% include "./sets-to-deferred.jinja" %} +Foo ends as: {{ foo }} diff --git a/src/test/resources/tags/eager/includetag/sets-to-deferred.jinja b/src/test/resources/tags/eager/includetag/sets-to-deferred.jinja new file mode 100644 index 000000000..953440208 --- /dev/null +++ b/src/test/resources/tags/eager/includetag/sets-to-deferred.jinja @@ -0,0 +1,2 @@ +{% set foo = deferred %} +foo is now {{ deferred }}. diff --git a/src/test/resources/tags/eager/settag/evaluates-expression.expected.jinja b/src/test/resources/tags/eager/settag/evaluates-expression.expected.jinja new file mode 100644 index 000000000..0611f0815 --- /dev/null +++ b/src/test/resources/tags/eager/settag/evaluates-expression.expected.jinja @@ -0,0 +1 @@ +{% set foo = [0, 1, 2] %} diff --git a/src/test/resources/tags/eager/settag/evaluates-expression.jinja b/src/test/resources/tags/eager/settag/evaluates-expression.jinja new file mode 100644 index 000000000..5a9df75c7 --- /dev/null +++ b/src/test/resources/tags/eager/settag/evaluates-expression.jinja @@ -0,0 +1 @@ +{% set foo = range(0, bar) %} diff --git a/src/test/resources/tags/eager/settag/handles-multiple-vars.expected.jinja b/src/test/resources/tags/eager/settag/handles-multiple-vars.expected.jinja new file mode 100644 index 000000000..706a5e7f8 --- /dev/null +++ b/src/test/resources/tags/eager/settag/handles-multiple-vars.expected.jinja @@ -0,0 +1 @@ +{% set foo, foobar = 3, range(6, deferred) %} diff --git a/src/test/resources/tags/eager/settag/handles-multiple-vars.jinja b/src/test/resources/tags/eager/settag/handles-multiple-vars.jinja new file mode 100644 index 000000000..fbc65c289 --- /dev/null +++ b/src/test/resources/tags/eager/settag/handles-multiple-vars.jinja @@ -0,0 +1 @@ +{% set foo, foobar = bar, range((baz/2 + bar)|int, deferred) %} diff --git a/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.expected.jinja b/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.expected.jinja new file mode 100644 index 000000000..49fdd28f6 --- /dev/null +++ b/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.expected.jinja @@ -0,0 +1 @@ +{% set foo = [[0, 1, 2], range(0, deferred)] %} diff --git a/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.jinja b/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.jinja new file mode 100644 index 000000000..7aded0c60 --- /dev/null +++ b/src/test/resources/tags/eager/settag/partially-evaluates-deferred-expression.jinja @@ -0,0 +1 @@ +{% set foo = [range(0,bar), range(0, deferred)] %} diff --git a/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.expected.jinja b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.expected.jinja new file mode 100644 index 000000000..a601c9e17 --- /dev/null +++ b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.expected.jinja @@ -0,0 +1,7 @@ +{% unless 1 != deferred %} +1 is {{ deferred }} +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endunless %} diff --git a/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.jinja b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.jinja new file mode 100644 index 000000000..5fccb1581 --- /dev/null +++ b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-eager.jinja @@ -0,0 +1,7 @@ +{% unless foo != deferred %} +{{ foo }} is {{ deferred }} +{% elif deferred > 0 %} +Positive {{ deferred }} +{% else %} +Negative {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.expected.jinja b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.expected.jinja new file mode 100644 index 000000000..df6fb86cc --- /dev/null +++ b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.expected.jinja @@ -0,0 +1 @@ +Yes {{ deferred }} diff --git a/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.jinja b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.jinja new file mode 100644 index 000000000..f1f76881f --- /dev/null +++ b/src/test/resources/tags/eager/unlesstag/handles-deferred-in-regular.jinja @@ -0,0 +1,5 @@ +{% unless foo %} +No {{ deferred }} +{% else %} +Yes {{ deferred }} +{% endif %} diff --git a/src/test/resources/tags/extendstag/a-extends-b.jinja b/src/test/resources/tags/extendstag/a-extends-b.jinja new file mode 100644 index 000000000..e14bed598 --- /dev/null +++ b/src/test/resources/tags/extendstag/a-extends-b.jinja @@ -0,0 +1,2 @@ +{% extends "b-extends-a.jinja" %} +A diff --git a/src/test/resources/tags/extendstag/b-extends-a.jinja b/src/test/resources/tags/extendstag/b-extends-a.jinja new file mode 100644 index 000000000..424005e58 --- /dev/null +++ b/src/test/resources/tags/extendstag/b-extends-a.jinja @@ -0,0 +1,2 @@ +{% extends "a-extends-b.jinja" %} +B diff --git a/src/test/resources/tags/extendstag/errors/block-error.html b/src/test/resources/tags/extendstag/errors/block-error.html new file mode 100644 index 000000000..47b9d46dd --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/block-error.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + +{% block test %} +{% set test = 'test' %} +{{ test|add(1) }} +{% endblock %} diff --git a/src/test/resources/tags/extendstag/errors/error.html b/src/test/resources/tags/extendstag/errors/error.html new file mode 100644 index 000000000..b4ca2b018 --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/error.html @@ -0,0 +1,6 @@ + + + +{% set test = 'test' %} +{{ test|add(1) }} + diff --git a/src/test/resources/tags/extendstag/errors/extends-block-error.jinja b/src/test/resources/tags/extendstag/errors/extends-block-error.jinja new file mode 100644 index 000000000..4698c788c --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/extends-block-error.jinja @@ -0,0 +1,5 @@ + + + + +{% extends "/errors/block-error.html" %} diff --git a/src/test/resources/tags/extendstag/errors/extends-include-block-error.jinja b/src/test/resources/tags/extendstag/errors/extends-include-block-error.jinja new file mode 100644 index 000000000..c031932f9 --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/extends-include-block-error.jinja @@ -0,0 +1,3 @@ + + +{% extends "/errors/include-block-error.html" %} diff --git a/src/test/resources/tags/extendstag/errors/extends-no-error.jinja b/src/test/resources/tags/extendstag/errors/extends-no-error.jinja new file mode 100644 index 000000000..ccc25f833 --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/extends-no-error.jinja @@ -0,0 +1,9 @@ +{% extends "/errors/no-error.html" %} + + + + +{% block test %} +{% set test = 'test' %} +{{ test|add(1) }} +{% endblock %} diff --git a/src/test/resources/tags/extendstag/errors/extends-outside-block-error.jinja b/src/test/resources/tags/extendstag/errors/extends-outside-block-error.jinja new file mode 100644 index 000000000..6f13f342b --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/extends-outside-block-error.jinja @@ -0,0 +1 @@ +{% extends "/errors/outside-block-error.html" %} diff --git a/src/test/resources/tags/extendstag/errors/include-block-error.html b/src/test/resources/tags/extendstag/errors/include-block-error.html new file mode 100644 index 000000000..eab33d6e5 --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/include-block-error.html @@ -0,0 +1,15 @@ + + + +{% block test %} + + +{% include "/errors/error.html" %} + + + +{% endblock %} + + +Blah + diff --git a/src/test/resources/tags/extendstag/errors/no-error.html b/src/test/resources/tags/extendstag/errors/no-error.html new file mode 100644 index 000000000..b1226af79 --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/no-error.html @@ -0,0 +1,2 @@ +{% block test %} +{% endblock %} diff --git a/src/test/resources/tags/extendstag/errors/outside-block-error.html b/src/test/resources/tags/extendstag/errors/outside-block-error.html new file mode 100644 index 000000000..a4e4ed63a --- /dev/null +++ b/src/test/resources/tags/extendstag/errors/outside-block-error.html @@ -0,0 +1,9 @@ +{% block test %} +{% endblock %} + + + + + +{% set test = 'test' %} +{{ test|add(1) }} diff --git a/src/test/resources/tags/extendstag/extends-self.jinja b/src/test/resources/tags/extendstag/extends-self.jinja new file mode 100644 index 000000000..9aa729054 --- /dev/null +++ b/src/test/resources/tags/extendstag/extends-self.jinja @@ -0,0 +1,4 @@ +{% extends "extends-self.jinja" %} +{% block header %} +hello world +{% endblock %} diff --git a/src/test/resources/tags/extendstag/hello.html b/src/test/resources/tags/extendstag/hello.html new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/src/test/resources/tags/extendstag/hello.html @@ -0,0 +1 @@ +hello diff --git a/src/test/resources/tags/extendstag/nested-relative-inheritance.html b/src/test/resources/tags/extendstag/nested-relative-inheritance.html new file mode 100644 index 000000000..998cdc1e3 --- /dev/null +++ b/src/test/resources/tags/extendstag/nested-relative-inheritance.html @@ -0,0 +1 @@ +{% include "./hello.html" %} \ No newline at end of file diff --git a/src/test/resources/tags/extendstag/rec1.jinja b/src/test/resources/tags/extendstag/rec1.jinja new file mode 100644 index 000000000..8cd52275c --- /dev/null +++ b/src/test/resources/tags/extendstag/rec1.jinja @@ -0,0 +1,4 @@ +{% extends 'rec2.jinja' %} +{% block body %} +foo +{% endblock %} diff --git a/src/test/resources/tags/extendstag/rec2.jinja b/src/test/resources/tags/extendstag/rec2.jinja new file mode 100644 index 000000000..19e51df69 --- /dev/null +++ b/src/test/resources/tags/extendstag/rec2.jinja @@ -0,0 +1,4 @@ +{% extends 'rec3.jinja' %} +{% block body %} +foobar +{% endblock %} diff --git a/src/test/resources/tags/extendstag/rec3.jinja b/src/test/resources/tags/extendstag/rec3.jinja new file mode 100644 index 000000000..eb4e1498f --- /dev/null +++ b/src/test/resources/tags/extendstag/rec3.jinja @@ -0,0 +1,4 @@ +{% extends 'rec3.jinja' %} +{% block body %} +baz +{% endblock %} diff --git a/src/test/resources/tags/extendstag/relative-block-2-base.jinja b/src/test/resources/tags/extendstag/relative-block-2-base.jinja new file mode 100644 index 000000000..7b140c9f6 --- /dev/null +++ b/src/test/resources/tags/extendstag/relative-block-2-base.jinja @@ -0,0 +1,3 @@ +{% block test %} +{% include "./hello.html" %} +{% endblock test %} diff --git a/src/test/resources/tags/extendstag/relative/nested-relative-extends-2.jinja b/src/test/resources/tags/extendstag/relative/nested-relative-extends-2.jinja new file mode 100644 index 000000000..8e74644ce --- /dev/null +++ b/src/test/resources/tags/extendstag/relative/nested-relative-extends-2.jinja @@ -0,0 +1 @@ +{% extends "./../nested-relative-inheritance.html" %} diff --git a/src/test/resources/tags/extendstag/relative/nested-relative-extends.jinja b/src/test/resources/tags/extendstag/relative/nested-relative-extends.jinja new file mode 100644 index 000000000..50d032135 --- /dev/null +++ b/src/test/resources/tags/extendstag/relative/nested-relative-extends.jinja @@ -0,0 +1,2 @@ +{% extends "./nested-relative-extends-2.jinja" %} + diff --git a/src/test/resources/tags/extendstag/relative/relative-block-2.jinja b/src/test/resources/tags/extendstag/relative/relative-block-2.jinja new file mode 100644 index 000000000..4af745c7d --- /dev/null +++ b/src/test/resources/tags/extendstag/relative/relative-block-2.jinja @@ -0,0 +1 @@ +{% extends "./../relative-block-2-base.jinja" %} diff --git a/src/test/resources/tags/extendstag/relative/relative-block.jinja b/src/test/resources/tags/extendstag/relative/relative-block.jinja new file mode 100644 index 000000000..495ad9dc7 --- /dev/null +++ b/src/test/resources/tags/extendstag/relative/relative-block.jinja @@ -0,0 +1,4 @@ +{% extends "./../super-base.html" %} +{% block sidebar %} +{% include "./../hello.html" %} +{% endblock %} diff --git a/src/test/resources/tags/extendstag/relative/relative-extends.jinja b/src/test/resources/tags/extendstag/relative/relative-extends.jinja new file mode 100644 index 000000000..84d24852a --- /dev/null +++ b/src/test/resources/tags/extendstag/relative/relative-extends.jinja @@ -0,0 +1,4 @@ +{% extends "./../super-base.html" %} +{% block sidebar %} +This is a relative path extends +{% endblock %} diff --git a/src/test/resources/tags/extendstag/tagcycleexception/template-a.jinja b/src/test/resources/tags/extendstag/tagcycleexception/template-a.jinja new file mode 100644 index 000000000..ca27bd9d2 --- /dev/null +++ b/src/test/resources/tags/extendstag/tagcycleexception/template-a.jinja @@ -0,0 +1,2 @@ +{% extends 'tags/extendstag/tagcycleexception/template-b.jinja' %} +{% extends 'tags/extendstag/tagcycleexception/template-d.jinja' %} \ No newline at end of file diff --git a/src/test/resources/tags/extendstag/tagcycleexception/template-b.jinja b/src/test/resources/tags/extendstag/tagcycleexception/template-b.jinja new file mode 100644 index 000000000..031f4c6fb --- /dev/null +++ b/src/test/resources/tags/extendstag/tagcycleexception/template-b.jinja @@ -0,0 +1 @@ +{% import 'tags/extendstag/tagcycleexception/template-c.jinja' %} \ No newline at end of file diff --git a/src/test/resources/tags/extendstag/tagcycleexception/template-c.jinja b/src/test/resources/tags/extendstag/tagcycleexception/template-c.jinja new file mode 100644 index 000000000..e2c00415e --- /dev/null +++ b/src/test/resources/tags/extendstag/tagcycleexception/template-c.jinja @@ -0,0 +1 @@ +Included text, won't be rendered \ No newline at end of file diff --git a/src/test/resources/tags/extendstag/tagcycleexception/template-d.jinja b/src/test/resources/tags/extendstag/tagcycleexception/template-d.jinja new file mode 100644 index 000000000..aa17aa787 --- /dev/null +++ b/src/test/resources/tags/extendstag/tagcycleexception/template-d.jinja @@ -0,0 +1 @@ +Extended text, will be rendered \ No newline at end of file diff --git a/src/test/resources/tags/fortag/loop-with-scalar.jinja b/src/test/resources/tags/fortag/loop-with-scalar.jinja new file mode 100644 index 000000000..6d000e655 --- /dev/null +++ b/src/test/resources/tags/fortag/loop-with-scalar.jinja @@ -0,0 +1,3 @@ +{% for number in the_list %} +

    The Number: {{ number }}

    +{% endfor %} diff --git a/src/test/resources/tags/iftag/if-object.jinja b/src/test/resources/tags/iftag/if-object.jinja new file mode 100644 index 000000000..914a75957 --- /dev/null +++ b/src/test/resources/tags/iftag/if-object.jinja @@ -0,0 +1,3 @@ +{% if foo %} +ifblock +{% endif %} \ No newline at end of file diff --git a/src/test/resources/tags/iftag/if-true-elif-true.jinja b/src/test/resources/tags/iftag/if-true-elif-true.jinja new file mode 100644 index 000000000..bfca25f54 --- /dev/null +++ b/src/test/resources/tags/iftag/if-true-elif-true.jinja @@ -0,0 +1,5 @@ +{% if true %} +one +{% elif true %} +two +{% endif %} diff --git a/src/test/resources/tags/importtag/errors/base.jinja b/src/test/resources/tags/importtag/errors/base.jinja new file mode 100644 index 000000000..a36e02e85 --- /dev/null +++ b/src/test/resources/tags/importtag/errors/base.jinja @@ -0,0 +1,4 @@ +{% set a = "foo" %} +{% import "tags/importtag/errors/file-with-error.jinja" %} +{% set test = 1 + 2 %} + diff --git a/src/test/resources/tags/importtag/errors/file-with-error.jinja b/src/test/resources/tags/importtag/errors/file-with-error.jinja new file mode 100644 index 000000000..b46a90e69 --- /dev/null +++ b/src/test/resources/tags/importtag/errors/file-with-error.jinja @@ -0,0 +1,11 @@ +{% set test = 'test' %} + + + + + + + + + +{{ test|add(1) }} diff --git a/src/test/resources/tags/importtag/errors/import-macro.jinja b/src/test/resources/tags/importtag/errors/import-macro.jinja new file mode 100644 index 000000000..04f63d639 --- /dev/null +++ b/src/test/resources/tags/importtag/errors/import-macro.jinja @@ -0,0 +1,3 @@ +{% import "tags/importtag/errors/macro-with-error.jinja" %} + +{{ doSomething() }} diff --git a/src/test/resources/tags/importtag/errors/include.jinja b/src/test/resources/tags/importtag/errors/include.jinja new file mode 100644 index 000000000..179cafc02 --- /dev/null +++ b/src/test/resources/tags/importtag/errors/include.jinja @@ -0,0 +1,7 @@ +{% set foo = "asdfas" %} + + + + + +{% include "tags/importtag/errors/base.jinja" %} diff --git a/src/test/resources/tags/importtag/errors/macro-with-error.jinja b/src/test/resources/tags/importtag/errors/macro-with-error.jinja new file mode 100644 index 000000000..1f50cf177 --- /dev/null +++ b/src/test/resources/tags/importtag/errors/macro-with-error.jinja @@ -0,0 +1,13 @@ +{% macro doSomething() %} +{% set test = 'test' %} + + + + + + + + + +{{ test|add(1) }} +{% endmacro %} diff --git a/src/test/resources/tags/importtag/imports-global-macro.jinja b/src/test/resources/tags/importtag/imports-global-macro.jinja new file mode 100644 index 000000000..95a862274 --- /dev/null +++ b/src/test/resources/tags/importtag/imports-global-macro.jinja @@ -0,0 +1 @@ +{% import "tags/macrotag/simple.jinja" %} diff --git a/src/test/resources/tags/importtag/imports-macro-relative.jinja b/src/test/resources/tags/importtag/imports-macro-relative.jinja new file mode 100644 index 000000000..7cc12fa17 --- /dev/null +++ b/src/test/resources/tags/importtag/imports-macro-relative.jinja @@ -0,0 +1 @@ +{% import "./../macrotag/simple-with-call.jinja" as test %} diff --git a/src/test/resources/tags/importtag/imports-macro.jinja b/src/test/resources/tags/importtag/imports-macro.jinja new file mode 100644 index 000000000..64e0d75e5 --- /dev/null +++ b/src/test/resources/tags/importtag/imports-macro.jinja @@ -0,0 +1 @@ +{% import "tags/macrotag/simple-with-call.jinja" as test %} diff --git a/src/test/resources/tags/importtag/imports-null.jinja b/src/test/resources/tags/importtag/imports-null.jinja new file mode 100644 index 000000000..f459c8f93 --- /dev/null +++ b/src/test/resources/tags/importtag/imports-null.jinja @@ -0,0 +1,4 @@ +{% set bar = 'bar' %} +{% import 'tags/importtag/null-value.jinja' %} +{{ foo }} +{{ bar }} diff --git a/src/test/resources/tags/importtag/null-value.jinja b/src/test/resources/tags/importtag/null-value.jinja new file mode 100644 index 000000000..b46d69206 --- /dev/null +++ b/src/test/resources/tags/importtag/null-value.jinja @@ -0,0 +1,2 @@ +{% set foo = 'foo' %} +{% set bar = null %} diff --git a/src/test/resources/tags/includetag/errors/base.html b/src/test/resources/tags/includetag/errors/base.html new file mode 100644 index 000000000..72ee7e66e --- /dev/null +++ b/src/test/resources/tags/includetag/errors/base.html @@ -0,0 +1,13 @@ +

    + Test file +

    + +{% set test = 1 + 1 %} + +{% include "tags/includetag/errors/error.html" %} + +

    + End test +

    + +{{ "test"|subtract(1) }} diff --git a/src/test/resources/tags/includetag/errors/base2.html b/src/test/resources/tags/includetag/errors/base2.html new file mode 100644 index 000000000..e7fdc66ce --- /dev/null +++ b/src/test/resources/tags/includetag/errors/base2.html @@ -0,0 +1,4 @@ +{% set test = 1 + 1 %} +{% include "tags/includetag/errors/base.html" %} + +{% set test = 3 + 1 %} diff --git a/src/test/resources/tags/includetag/errors/error.html b/src/test/resources/tags/includetag/errors/error.html new file mode 100644 index 000000000..b91292f3e --- /dev/null +++ b/src/test/resources/tags/includetag/errors/error.html @@ -0,0 +1,4 @@ +

    + Error page +

    +{{ "test"|add(1) }} diff --git a/src/test/resources/tags/includetag/folder/relative-include.html b/src/test/resources/tags/includetag/folder/relative-include.html new file mode 100644 index 000000000..fd5f9f5d5 --- /dev/null +++ b/src/test/resources/tags/includetag/folder/relative-include.html @@ -0,0 +1 @@ +INCLUDED diff --git a/src/test/resources/tags/includetag/include-with-import.jinja b/src/test/resources/tags/includetag/include-with-import.jinja new file mode 100644 index 000000000..4357069d2 --- /dev/null +++ b/src/test/resources/tags/includetag/include-with-import.jinja @@ -0,0 +1 @@ +{% include "tags/macrotag/simple-with-call.jinja" %} diff --git a/src/test/resources/tags/includetag/includes-relative-path.jinja b/src/test/resources/tags/includetag/includes-relative-path.jinja new file mode 100644 index 000000000..2f186a19a --- /dev/null +++ b/src/test/resources/tags/includetag/includes-relative-path.jinja @@ -0,0 +1 @@ +{% include "./folder/relative-include.html" %} diff --git a/src/test/resources/tags/includetag/raw.html b/src/test/resources/tags/includetag/raw.html new file mode 100644 index 000000000..1a85407de --- /dev/null +++ b/src/test/resources/tags/includetag/raw.html @@ -0,0 +1 @@ +before{% raw %} raw{% endraw %} after diff --git a/src/test/resources/tags/macrotag/double-import-macro.jinja b/src/test/resources/tags/macrotag/double-import-macro.jinja new file mode 100644 index 000000000..6696dba03 --- /dev/null +++ b/src/test/resources/tags/macrotag/double-import-macro.jinja @@ -0,0 +1,5 @@ +{% import 'import-macro.jinja' -%} +{% macro print_path_macro2(var) -%} +{{ var|print_path }} +{{ print_path_macro(var) }} +{%- endmacro %} diff --git a/src/test/resources/tags/macrotag/ending-recursion.jinja b/src/test/resources/tags/macrotag/ending-recursion.jinja new file mode 100644 index 000000000..91b67c8ae --- /dev/null +++ b/src/test/resources/tags/macrotag/ending-recursion.jinja @@ -0,0 +1,5 @@ +{% macro hello(count) -%} + Hello {% if count > 0 %}{{ hello(count-1) }}{% endif %} +{%- endmacro %} + +{{ hello(5) }} diff --git a/src/test/resources/tags/macrotag/from-a-to-b.jinja b/src/test/resources/tags/macrotag/from-a-to-b.jinja new file mode 100644 index 000000000..a7ff13e82 --- /dev/null +++ b/src/test/resources/tags/macrotag/from-a-to-b.jinja @@ -0,0 +1,4 @@ +{% from "from-b-to-c.jinja" import wrap_padding as wp, spacer %} + +wrap-padding: {{ wp }} +wrap-spacer: {{ spacer() }} diff --git a/src/test/resources/tags/macrotag/from-alias-macro.jinja b/src/test/resources/tags/macrotag/from-alias-macro.jinja new file mode 100644 index 000000000..fdfb0ed21 --- /dev/null +++ b/src/test/resources/tags/macrotag/from-alias-macro.jinja @@ -0,0 +1,4 @@ +{% from "pegasus-importable.jinja" import wrap_padding as wp, spacer as sp %} + +wrap-padding: {{ wp }} +wrap-spacer: {{ sp() }} \ No newline at end of file diff --git a/src/test/resources/tags/macrotag/from-b-to-c.jinja b/src/test/resources/tags/macrotag/from-b-to-c.jinja new file mode 100644 index 000000000..f0525647b --- /dev/null +++ b/src/test/resources/tags/macrotag/from-b-to-c.jinja @@ -0,0 +1,4 @@ +{% from "from-c-to-a.jinja" import wrap_padding as wp, spacer %} + +wrap-padding: {{ wp }} +wrap-spacer: {{ spacer() }} diff --git a/src/test/resources/tags/macrotag/from-c-to-a.jinja b/src/test/resources/tags/macrotag/from-c-to-a.jinja new file mode 100644 index 000000000..f0525647b --- /dev/null +++ b/src/test/resources/tags/macrotag/from-c-to-a.jinja @@ -0,0 +1,4 @@ +{% from "from-c-to-a.jinja" import wrap_padding as wp, spacer %} + +wrap-padding: {{ wp }} +wrap-spacer: {{ spacer() }} diff --git a/src/test/resources/tags/macrotag/from-recursion.jinja b/src/test/resources/tags/macrotag/from-recursion.jinja new file mode 100644 index 000000000..ef98a5110 --- /dev/null +++ b/src/test/resources/tags/macrotag/from-recursion.jinja @@ -0,0 +1,4 @@ +{% from "from-recursion.jinja" import wrap_padding as wp, spacer %} + +wrap-padding: {{ wp }} +wrap-spacer: {{ spacer() }} diff --git a/src/test/resources/tags/macrotag/from-simple-with-call.jinja b/src/test/resources/tags/macrotag/from-simple-with-call.jinja new file mode 100644 index 000000000..7ee74c6f0 --- /dev/null +++ b/src/test/resources/tags/macrotag/from-simple-with-call.jinja @@ -0,0 +1 @@ +{% from "simple-with-call.jinja" import getPath %} diff --git a/src/test/resources/tags/macrotag/import-macro.jinja b/src/test/resources/tags/macrotag/import-macro.jinja new file mode 100644 index 000000000..6c25a1782 --- /dev/null +++ b/src/test/resources/tags/macrotag/import-macro.jinja @@ -0,0 +1,4 @@ +{% macro print_path_macro(var) %} +{{ var|print_path }} +{{ var }} +{% endmacro %} diff --git a/src/test/resources/tags/macrotag/import-property-global.jinja b/src/test/resources/tags/macrotag/import-property-global.jinja new file mode 100644 index 000000000..3172cee2e --- /dev/null +++ b/src/test/resources/tags/macrotag/import-property-global.jinja @@ -0,0 +1,3 @@ +{% import "tags/settag/set-var-exp.jinja" %} + +{{ primary_line_height }} diff --git a/src/test/resources/tags/macrotag/import-property.jinja b/src/test/resources/tags/macrotag/import-property.jinja new file mode 100644 index 000000000..d1171f86c --- /dev/null +++ b/src/test/resources/tags/macrotag/import-property.jinja @@ -0,0 +1,7 @@ +{% import "tags/settag/set-var-exp.jinja" as pegasus %} + +{{ pegasus.primary_line_height }} +{{ pegasus.secondary_line_height }} + +{% set expected_to_be_deferred = pegasus.secondary_line_height %} +{{ expected_to_be_deferred }} diff --git a/src/test/resources/tags/macrotag/include-two-macros.jinja b/src/test/resources/tags/macrotag/include-two-macros.jinja new file mode 100644 index 000000000..7b3cf721d --- /dev/null +++ b/src/test/resources/tags/macrotag/include-two-macros.jinja @@ -0,0 +1,3 @@ +{% from "tags/macrotag/two-macros.jinja" import macro2 %} + +{{ macro2() }} \ No newline at end of file diff --git a/src/test/resources/tags/macrotag/list_kwargs.jinja b/src/test/resources/tags/macrotag/list_kwargs.jinja new file mode 100644 index 000000000..2acbde314 --- /dev/null +++ b/src/test/resources/tags/macrotag/list_kwargs.jinja @@ -0,0 +1,5 @@ +{% macro list_kwargs() %} + {% for k,v in kwargs.items() %} + {{k}}={{v}} + {% endfor %} +{% endmacro %} diff --git a/src/test/resources/tags/macrotag/macros-calling-macros.jinja b/src/test/resources/tags/macrotag/macros-calling-macros.jinja new file mode 100644 index 000000000..e1e7fe288 --- /dev/null +++ b/src/test/resources/tags/macrotag/macros-calling-macros.jinja @@ -0,0 +1,26 @@ +{% macro render_dialog_one(title, class) %} +
    +

    {{ title }}

    +
    + {{ caller() }} +
    +
    +{% endmacro %} + +{% macro render_dialog_two(title, class) %} +
    +

    {{ title }}

    +
    + {{ caller() }} +
    +
    +{% endmacro %} + +{% call render_dialog_one('Hello World One', 'greeting 1') %} +

    1) This is render_dialog_one

    + + {% call render_dialog_two('Hello World Two', 'greeting 2') %} +

    2) This is render_dialog_two

    + {% endcall %} + +{% endcall %} diff --git a/src/test/resources/tags/macrotag/recursion.jinja b/src/test/resources/tags/macrotag/recursion.jinja new file mode 100644 index 000000000..8e27ba776 --- /dev/null +++ b/src/test/resources/tags/macrotag/recursion.jinja @@ -0,0 +1,5 @@ +{% macro hello() -%} + Hello {{ hello() }} +{%- endmacro %} + +{{ hello() }} diff --git a/src/test/resources/tags/macrotag/recursion_indirect.jinja b/src/test/resources/tags/macrotag/recursion_indirect.jinja new file mode 100644 index 000000000..b631b83f1 --- /dev/null +++ b/src/test/resources/tags/macrotag/recursion_indirect.jinja @@ -0,0 +1,9 @@ +{% macro hello() -%} + Hello, {{ goodbye() }} +{%- endmacro %} + +{% macro goodbye() -%} + Goodbye, {{ hello() }} +{%- endmacro %} + +{{ goodbye() }} diff --git a/src/test/resources/tags/macrotag/relative-from.jinja b/src/test/resources/tags/macrotag/relative-from.jinja new file mode 100644 index 000000000..e01f8343e --- /dev/null +++ b/src/test/resources/tags/macrotag/relative-from.jinja @@ -0,0 +1 @@ +{% from "./../simple-with-call.jinja" import getPath %} diff --git a/src/test/resources/tags/macrotag/scoping.jinja b/src/test/resources/tags/macrotag/scoping.jinja new file mode 100644 index 000000000..247bf7a20 --- /dev/null +++ b/src/test/resources/tags/macrotag/scoping.jinja @@ -0,0 +1,12 @@ +{%- macro foo() -%} +{%- macro foo() -%} +child +{%- endmacro %} +{%- macro bar() -%} +the bar +{%- endmacro -%} +parent & {{ foo() }} & {{ bar() }} +{%- endmacro %} +{{ foo() }}. +{{ foo() }}. +{{ bar() }}. diff --git a/src/test/resources/tags/macrotag/simple-no-trim.jinja b/src/test/resources/tags/macrotag/simple-no-trim.jinja new file mode 100644 index 000000000..e8711c7e8 --- /dev/null +++ b/src/test/resources/tags/macrotag/simple-no-trim.jinja @@ -0,0 +1,14 @@ +{% macro getPath() %} + + + + + + + Hello {{ myname }} + + + + + +{% endmacro %} diff --git a/src/test/resources/tags/macrotag/simple-with-call.jinja b/src/test/resources/tags/macrotag/simple-with-call.jinja new file mode 100644 index 000000000..243ecf68a --- /dev/null +++ b/src/test/resources/tags/macrotag/simple-with-call.jinja @@ -0,0 +1,5 @@ +{% macro getPath() -%} + Hello {{ myname }} +{%- endmacro %} + +{{ getPath() }} diff --git a/src/test/resources/tags/macrotag/two-macros.jinja b/src/test/resources/tags/macrotag/two-macros.jinja new file mode 100644 index 000000000..92f87f152 --- /dev/null +++ b/src/test/resources/tags/macrotag/two-macros.jinja @@ -0,0 +1,9 @@ +{% macro macro1(options={}) %} + +{% endmacro %} + +{% macro macro2(options={}) %} + + + {% call macro1() %}{% endcall %} +{% endmacro %} diff --git a/src/test/resources/tags/rawtag/deferred.jinja b/src/test/resources/tags/rawtag/deferred.jinja new file mode 100644 index 000000000..8af7865b1 --- /dev/null +++ b/src/test/resources/tags/rawtag/deferred.jinja @@ -0,0 +1 @@ +{% raw %}{{ deferred }}{% endraw %} diff --git a/src/test/resources/tags/rawtag/nospacing.jinja b/src/test/resources/tags/rawtag/nospacing.jinja new file mode 100644 index 000000000..918f93798 --- /dev/null +++ b/src/test/resources/tags/rawtag/nospacing.jinja @@ -0,0 +1,3 @@ +{%raw%} +{%print 'foo'%} +{%endraw%} diff --git a/src/test/resources/tags/settag/set-dictionary.jinja b/src/test/resources/tags/settag/set-dictionary.jinja index 84d3f6ec3..f92d26b39 100644 --- a/src/test/resources/tags/settag/set-dictionary.jinja +++ b/src/test/resources/tags/settag/set-dictionary.jinja @@ -1 +1 @@ -{% set the_dictionary = {"seven": i_am_seven, "the_list": the_list, "inner_list_literal": ["heya", False], i_am_seven: 7, "inner_dict": {"car keys": "valuable"} } %} +{% set the_dictionary = {"seven": i_am_seven, "the_list": the_list, "inner_list_literal": ["heya", false], "i_am_seven": 7, "inner_dict": {"car keys": "valuable"} } %} diff --git a/src/test/resources/tags/settag/set-list-modify.jinja b/src/test/resources/tags/settag/set-list-modify.jinja new file mode 100644 index 000000000..ce42c0b55 --- /dev/null +++ b/src/test/resources/tags/settag/set-list-modify.jinja @@ -0,0 +1 @@ +{% set _noop= show_count.set(0, show) %} diff --git a/src/test/resources/tags/settag/set-multivar-unbalanced-vals.jinja b/src/test/resources/tags/settag/set-multivar-unbalanced-vals.jinja new file mode 100644 index 000000000..4963a7f35 --- /dev/null +++ b/src/test/resources/tags/settag/set-multivar-unbalanced-vals.jinja @@ -0,0 +1,5 @@ +{% set myvar1, myvar2, myvar3, myvar4 = + 'foo', + bar, + "yoooooo" +%} diff --git a/src/test/resources/tags/settag/set-multivar-unbalanced-vars.jinja b/src/test/resources/tags/settag/set-multivar-unbalanced-vars.jinja new file mode 100644 index 000000000..d494641c4 --- /dev/null +++ b/src/test/resources/tags/settag/set-multivar-unbalanced-vars.jinja @@ -0,0 +1,6 @@ +{% set myvar1, myvar2, myvar3 = + 'foo', + bar, + (1, 2, 3, 4), + "yoooooo" +%} diff --git a/src/test/resources/tags/settag/set-multivar.jinja b/src/test/resources/tags/settag/set-multivar.jinja new file mode 100644 index 000000000..2c3156505 --- /dev/null +++ b/src/test/resources/tags/settag/set-multivar.jinja @@ -0,0 +1,6 @@ +{% set myvar1, myvar2, myvar3, myvar4 = + 'foo', + bar, + (1, 2, 3, 4), + "yoooooo" +%} diff --git a/src/test/resources/tags/settag/set-var-exp.jinja b/src/test/resources/tags/settag/set-var-exp.jinja index 656eca206..7c69d578e 100644 --- a/src/test/resources/tags/settag/set-var-exp.jinja +++ b/src/test/resources/tags/settag/set-var-exp.jinja @@ -1 +1,2 @@ -{% set primary_line_height = primary_font_size_num*1.5 %} \ No newline at end of file +{% set primary_line_height = primary_font_size_num*1.5 %} +{% set secondary_line_height = 1.5 %} diff --git a/src/test/resources/varblocks/string-range.html b/src/test/resources/varblocks/string-range.html new file mode 100644 index 000000000..cff8d517a --- /dev/null +++ b/src/test/resources/varblocks/string-range.html @@ -0,0 +1 @@ +{{ theString[2:5] }} \ No newline at end of file