Repository: PebbleTemplates/pebble
Pull request #648 (2023-03-18 01:11:25)

Add native hints
In order to let any Java project including PebbleTemplates as a dependency to be able to build and run a native image using the AOT-compiler GraalVM, it is necessary to add native hints to the GraalVM so that it can maintain class and variables information during compile time (and consequently runtime) to be used as information for Reflection, Proxies and other JVM mechanisms.

This will add the necessary TypeHints for the SpringBoot starter only, in order to enable the building of a project using Spring Boot as framework, and to promote further experiments with other frameworks.

noblehelm: Proposed fix for #648 
noblehelm: Unfortunately, even with the added hints for the ForNode class, for the inner LoopVariable class, it is not possible to let the native image reach its fields due to the fact that it is a private class. So, even with the hints, we end up with a similar error to this one:

`2023-03-18 00:29:24.390 -ERROR 1 --- [nio-7300-exec-3] o.a.c.c.C.[.[.[.[dispatcherServlet]      : Servlet.service() for servlet [dispatcherServlet] in context with path [/api/planrepository] threw exception [Request processing failed: io.pebbletemplates.pebble.error.PebbleException: Could not perform addition (**template**:6)] with root cause

io.pebbletemplates.pebble.error.AttributeNotFoundException: Attribute [index] of [io.pebbletemplates.pebble.node.ForNode$LoopVariables] does not exist or can not be accessed and strict variables is set to true. (**template**:6)
	at io.pebbletemplates.pebble.node.expression.GetAttributeExpression.evaluate(GetAttributeExpression.java:91) ~[native:na]
	at io.pebbletemplates.pebble.node.expression.AddExpression.evaluate(AddExpression.java:22) ~[native:na]
	at io.pebbletemplates.pebble.node.expression.FilterExpression.evaluate(FilterExpression.java:66) ~[native:na]
	at io.pebbletemplates.pebble.node.PrintNode.render(PrintNode.java:37) ~[na:na]
	at io.pebbletemplates.pebble.node.BodyNode.render(BodyNode.java:44) ~[native:na]
	at io.pebbletemplates.pebble.node.ForNode.render(ForNode.java:125) ~[native:na]
	at io.pebbletemplates.pebble.node.BodyNode.render(BodyNode.java:44) ~[native:na]
	at io.pebbletemplates.pebble.node.RootNode.render(RootNode.java:31) ~[na:na]
	at io.pebbletemplates.pebble.template.PebbleTemplateImpl.evaluate(PebbleTemplateImpl.java:157) ~[native:na]
	at io.pebbletemplates.pebble.template.PebbleTemplateImpl.evaluate(PebbleTemplateImpl.java:96) ~[native:na]
`

Is there any way to solve this?
ebussieres: Never had the chance to try GraalVM. Can't help you on that one

Patch:
diff --git a/pebble-spring/pebble-spring-boot-starter/src/main/java/io/pebbletemplates/boot/autoconfigure/PebbleAutoConfiguration.java b/pebble-spring/pebble-spring-boot-starter/src/main/java/io/pebbletemplates/boot/autoconfigure/PebbleAutoConfiguration.java
index 2d858ea0..813c112f 100644
--- a/pebble-spring/pebble-spring-boot-starter/src/main/java/io/pebbletemplates/boot/autoconfigure/PebbleAutoConfiguration.java
+++ b/pebble-spring/pebble-spring-boot-starter/src/main/java/io/pebbletemplates/boot/autoconfigure/PebbleAutoConfiguration.java
@@ -5,9 +5,14 @@ import io.pebbletemplates.pebble.attributes.methodaccess.MethodAccessValidator;
 import io.pebbletemplates.pebble.extension.Extension;
 import io.pebbletemplates.pebble.loader.ClasspathLoader;
 import io.pebbletemplates.pebble.loader.Loader;
+import io.pebbletemplates.pebble.node.ForNode;
+import io.pebbletemplates.pebble.node.expression.UnaryMinusExpression;
 import io.pebbletemplates.spring.extension.SpringExtension;
 import java.util.List;
-
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.TypeReference;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -67,3 +72,17 @@ public class PebbleAutoConfiguration extends AbstractPebbleConfiguration {
     return builder.build();
   }
 }
+
+class PebbleTemplatesHints implements RuntimeHintsRegistrar {
+
+  @Override
+  public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+    hints.reflection()
+        .registerType(TypeReference.of(UnaryMinusExpression.class),
+            hint -> hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS))
+        .registerType(TypeReference.of(ForNode.LoopVariables.class),
+            hint -> hint.withMembers(MemberCategory.DECLARED_FIELDS,
+                MemberCategory.DECLARED_CLASSES, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
+                MemberCategory.INVOKE_DECLARED_METHODS));
+  }
+}
diff --git a/pebble-spring/pebble-spring-boot-starter/src/main/resources/META-INF/spring/aot.factories b/pebble-spring/pebble-spring-boot-starter/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000..822b7d87
--- /dev/null
+++ b/pebble-spring/pebble-spring-boot-starter/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+io.pebbletemplates.boot.autoconfigure.PebbleTemplatesHints
\ No newline at end of file
diff --git a/pebble/src/main/java/io/pebbletemplates/pebble/extension/core/ReplaceFilter.java b/pebble/src/main/java/io/pebbletemplates/pebble/extension/core/ReplaceFilter.java
index f3863563..cdc2a344 100644
--- a/pebble/src/main/java/io/pebbletemplates/pebble/extension/core/ReplaceFilter.java
+++ b/pebble/src/main/java/io/pebbletemplates/pebble/extension/core/ReplaceFilter.java
@@ -32,14 +32,16 @@ public class ReplaceFilter implements Filter {
   @Override
   public Object apply(Object input, Map<String, Object> args, PebbleTemplate self,
                       EvaluationContext context, int lineNumber) throws PebbleException {
-    String data = input.toString();
+    if (input == null) {
+      return null;
+    }
     if (args.get(ARGUMENT_NAME) == null) {
       throw new PebbleException(null,
           MessageFormat.format("The argument ''{0}'' is required.", ARGUMENT_NAME), lineNumber,
           self.getName());
     }
     Map<?, ?> replacePair = (Map<?, ?>) args.get(ARGUMENT_NAME);
-
+    String data = input.toString();
     for (Entry<?, ?> entry : replacePair.entrySet()) {
       data = data.replace(entry.getKey().toString(), entry.getValue().toString());
     }
diff --git a/pebble/src/main/java/io/pebbletemplates/pebble/node/ForNode.java b/pebble/src/main/java/io/pebbletemplates/pebble/node/ForNode.java
index 25dc16f0..ca172538 100644
--- a/pebble/src/main/java/io/pebbletemplates/pebble/node/ForNode.java
+++ b/pebble/src/main/java/io/pebbletemplates/pebble/node/ForNode.java
@@ -49,16 +49,36 @@ public class ForNode extends AbstractRenderableNode {
     this.elseBody = elseBody;
   }
 
-  private static class LoopVariables {
-    public boolean first, last;
-    public LazyLength length;
-    public int index;
-    public LazyRevIndex revindex;
+  public static class LoopVariables {
+    private boolean first, last;
+    private LazyLength length;
+    private int index;
+    private LazyRevIndex revindex;
 
     @Override
     public String toString() {
       return "{last=" + last + ", length=" + length + ", index=" + index + ", revindex=" + revindex + ", first=" + first + "}";
     }
+
+    public boolean isFirst() {
+      return first;
+    }
+
+    public boolean isLast() {
+      return last;
+    }
+
+    public LazyLength getLength() {
+      return length;
+    }
+
+    public int getIndex() {
+      return index;
+    }
+
+    public LazyRevIndex getRevindex() {
+      return revindex;
+    }
   }
 
   @Override


Test patch:
diff --git a/pebble-spring/pebble-spring-boot-starter/src/test/resources/templates/native-image.peb b/pebble-spring/pebble-spring-boot-starter/src/test/resources/templates/native-image.peb
new file mode 100644
index 00000000..9e2a23ad
--- /dev/null
+++ b/pebble-spring/pebble-spring-boot-starter/src/test/resources/templates/native-image.peb
@@ -0,0 +1,232 @@
+{{ foo.bar }}
+
+{{ foo["bar"] }}
+
+{{ bar[0] }}
+
+{{ "If life gives you lemons, eat lemons." | upper | abbreviate(13) }}
+
+{{ max(13, highscore) }}
+
+{% for article in articles %}
+    <p>{{ article }}</p>
+{% else %}
+    <p> There are no articles. </p>
+{% endfor %}
+
+{% if category == "news" %}
+{{ news }}
+    {% elseif category == "sports" %}
+{{ sports }}
+    {% else %}
+    <p>Please select a category</p>
+{% endif %}
+
+{{ stringDate | date(existingFormat="yyyy-MMMM-d", format="yyyy/MMMM/d") }}
+
+{{ stringDate | date("yyyy/MMMM/d", existingFormat="yyyy-MMMM-d") }}
+
+{% set danger = "<br>" %}
+{{ danger }}
+
+{% set danger = "<br>" %}
+{{ danger | escape }}
+
+{# THIS IS A COMMENT #}
+{% if 3 is odd %}
+	...
+{% endif %}
+
+{% for article in articles %}
+    <p>number: ; content: {{ article }}</p>
+{% else %}
+    <p> There are no articles. </p>
+{% endfor %}
+
+{% if name is not null %}
+	...
+{% endif %}
+
+{{ folk ? "yes" : "no" }}
+
+{{ user.name | capitalize }}
+
+{% if user.name equals "Mitchell" %}
+	David
+{% endif %}
+
+    {% if bar contains 2 %}
+        Two
+    {% endif %}
+
+{% if (3 is not even) and (2 is odd or 3 is even) %}
+	...
+{% endif %}
+
+{% if user.telephone is empty %}
+	...
+{% endif %}
+
+{% if user is not map %}
+	...
+{% endif %}
+
+{% if user.email is null %}
+	user.email is null
+{% endif %}
+
+{% if users is iterable %}
+    {% for user in users %}
+		User: {{ user }}
+	{% endfor %}
+{% endif %}
+
+{{ i18n("messages","greetings") }}
+
+{% for i in range(0, 36, 2) %}
+{{ i }},
+{% endfor %}
+
+{{ "this is a long sentence." | abbreviate(7) }}
+
+{{ -7 | abs }}
+
+{{ "dGVzdA==" | base64decode }}
+
+{{ "test" | base64encode }}
+
+{{ "article title" | capitalize }}
+
+{{ "July 24, 2001" | date("yyyy-MM-dd", existingFormat="MMMM dd, yyyy") }}
+
+{{ user.telephone | default("No phone number") }}
+
+{{ "<div>" | escape }}
+
+{{ users | first }}
+{# will output the first item in the collection named 'users' #}
+
+{{ 'Mitch' | first }}
+{# will output 'M' #}
+
+{{ names | join(',') }}
+{# will output: Alex,Joe,Bob #}
+
+{{ users | last }}
+{# will output the last item in the collection named 'users' #}
+
+{{ 'Mitch' | last }}
+{# will output 'h' #}
+
+{% if users|length > 10 %}
+    ...
+{% endif %}
+
+{{ "THIS IS A LOUD SENTENCE" | lower }}
+
+{{ 3.141592653 | numberformat("#.##") }}
+
+{% set danger = "<div>" %}
+{{ danger | upper | raw }}
+{# ouptut: <DIV> #}
+
+ {% set danger = "<div>" %}
+{{ danger | raw | upper }}
+{# output: &lt;DIV&gt; #}
+
+{{ "I like %this% and %that%." | replace({'%this%': foo, '%that%': "bar"}) }}
+
+{% for user in users | reverse %} {{ user }} {% endfor %}
+
+{% for user in users | rsort %}
+    {{ user }}
+{% endfor %}
+
+{{ "test" | sha256 }}
+
+{{ ['apple', 'peach', 'pear', 'banana'] | slice(1,3) }}
+{# results in: [peach, pear] #}
+
+{{ 'Mitchell' | slice(1,3) }}
+{# results in: 'it' #}
+
+{% for user in users | sort %}
+    {{ user }}
+{% endfor %}
+
+{% set foo = "one,two,three" | split(',') %}
+{# foo contains ['one', 'two', 'three'] #}
+
+{% set foo = "one,two,three,four,five" | split(',', 3) %}
+{# foo contains ['one', 'two', 'three,four,five'] #}
+
+{{ "article title" | title }}
+
+{{ "    This text has too much whitespace.    " | trim }}
+
+{{ "this is a quiet sentence." | upper }}
+
+{{ "The string ü@foo-bar" | urlencode }}
+
+{% block "post" %} content {% endblock %}
+
+{{ block("post") }}
+
+{{ block("post") }}
+
+
+{{ "this is a long sentence." | abbreviate(7) }}
+
+{{ danger }} {# will be escaped by default #}
+{% autoescape false %}
+{{ danger }} {# will not be escaped #}
+{% endautoescape %}
+
+{{ danger }} {# will use the "html" escaping strategy #}
+{% autoescape "js" %}
+{{ danger }} {# will use the "js" escaping strategy #}
+{% endautoescape %}
+
+{% cache 'menu' %}
+    {% for item in items %}
+    {{ item.text }}
+        ....
+    {% endfor %}
+{% endcache %}
+
+{% filter upper %}
+	hello
+{% endfilter %}
+
+{% filter upper | escape %}
+	hello<br>
+{% endfilter %}
+
+{# output: 'HELLO&lt;br&gt;' #}
+
+{{ headerText }}
+    {% flush %}
+{{ content }}
+
+{% for user in users %}
+    {{ loop.index }}
+    {{ loop.length }}
+    {{ loop.first }}
+    {{ loop.last }}
+    {{ loop.revindex }}
+{% endfor %}
+
+{% include "advertisement.peb" with {"foo": "bar"} %}
+
+{% macro input(type="text", name, value) %}
+	<input type="{{ type }}" name="{{ name }}" value="{{ value }}" />
+{% endmacro %}
+
+{{ input(name="country") }}
+{# will output: <input type="text" name="country" value="" /> #}
+
+{% verbatim %}
+	{% for user in users %}
+		{{ user.name }}
+	{% endfor %}
+{% endverbatim %}
diff --git a/pebble/src/test/java/io/pebbletemplates/pebble/CoreFiltersTest.java b/pebble/src/test/java/io/pebbletemplates/pebble/CoreFiltersTest.java
index f8e7abcf..f1ab66bd 100644
--- a/pebble/src/test/java/io/pebbletemplates/pebble/CoreFiltersTest.java
+++ b/pebble/src/test/java/io/pebbletemplates/pebble/CoreFiltersTest.java
@@ -12,7 +12,6 @@ import io.pebbletemplates.pebble.error.PebbleException;
 import io.pebbletemplates.pebble.extension.TestingExtension;
 import io.pebbletemplates.pebble.extension.core.*;
 import io.pebbletemplates.pebble.loader.StringLoader;
-import io.pebbletemplates.pebble.extension.core.*;
 import io.pebbletemplates.pebble.template.PebbleTemplate;
 
 import org.junit.jupiter.api.Test;
@@ -30,6 +29,7 @@ import static java.lang.Boolean.TRUE;
 import static java.util.Collections.emptyList;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 
 class CoreFiltersTest {
 
@@ -1309,6 +1309,22 @@ class CoreFiltersTest {
     assertEquals("4", writer.toString());
   }
 
+  /**
+   * Tests {@link ReplaceFilter} if it can handle a null input.
+   */
+  @Test
+  void testReplaceFilterNullInput() throws PebbleException, IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().loader(new StringLoader())
+            .strictVariables(false).build();
+
+    PebbleTemplate template = pebble
+            .getTemplate(
+                    "{{ null |replace({'%this%': foo, '%that%': \"bar\"}) }}");
+
+    Writer writer = new StringWriter();
+    assertDoesNotThrow(() -> template.evaluate(writer, new HashMap<>()));
+  }
+
   /**
    * Tests {@link ReplaceFilter} if the length filter is working within templates.
    */