Repository: PebbleTemplates/pebble
Pull request #503 (2020-04-22 10:58:04)

Allow treating all number literals as BigDecimals to avoid NumberFormatExceptions
If a literal is big enough, with current implementation there will be a NumberFormatException thrown during the processing of the Template. There should be an option to treat all number literals as BigDecimals. It will help avoiding this exception and also enforce precise arithmetics when performing calculations.

ebussieres: I'll need to run some benchmark with these changes. But it sounds good to me 
eXsio: Sure, docs updated. Setting this option to true may indeed cause some small performance degradation (BigDecimals are slower than Longs/Doubles by nature). I'd still keep it for folks that would rather sacrifice a bit of performance but gain 100% precision when doing math.

In my use case I'm using Pebble as an expression evaluation engine (to calculate Excel-like formulas using my own set of Functions and Operators) and this option is vital to me - without it I have to make workarounds by creating custom Functions to explicitly convert String-based args to BigDecimals.

Patch:
diff --git a/docs/src/orchid/resources/wiki/guide/installation.md b/docs/src/orchid/resources/wiki/guide/installation.md
index d922580c..cee1b813 100644
--- a/docs/src/orchid/resources/wiki/guide/installation.md
+++ b/docs/src/orchid/resources/wiki/guide/installation.md
@@ -86,4 +86,5 @@ All the settings are set during the construction of the `PebbleEngine` object.
 | `strictVariables` | If set to true, Pebble will throw an exception if you try to access a variable or attribute that does not exist (or an attribute of a null variable). If set to false, your template will treat non-existing variables/attributes as null without ever skipping a beat. | `false` |
 | `allowUnsafeMethods` | If set to false, Pebble will throw an exception if you try to access unsafe methods. Unsafe methods are defined [here](https://github.com/PebbleTemplates/pebble/tree/master/pebble/src/main/resources/unsafeMethods.properties) | `true` in 2.x, 'false' in v3.x |
 | `literalDecimalTreatedAsInteger` | option for toggling to enable/disable literal decimal treated as integer | `false` |
+| `literalNumbersAsBigDecimals` | option for toggling to enable/disable literal numbers treated as BigDecimals | `false` |
 | `greedyMatchMethod` | option for toggling to enable/disable greedy matching mode for finding java method. Reduce the limit of the parameter type, try to find other method which has compatible parameter types. | `false` |
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java b/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
index 276588c6..55b15c1f 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
@@ -164,7 +164,7 @@ public class PebbleEngine {
       
       Parser parser = new ParserImpl(this.extensionRegistry.getUnaryOperators(),
           this.extensionRegistry.getBinaryOperators(), this.extensionRegistry.getTokenParsers(),
-          this.parserOptions);
+              this.parserOptions);
       RootNode root = parser.parse(tokenStream);
 
       PebbleTemplateImpl instance = new PebbleTemplateImpl(this, root, templateName);
@@ -291,6 +291,8 @@ public class PebbleEngine {
 
     private boolean allowOverrideCoreOperators = false;
 
+    private boolean literalNumbersAsBigDecimals = false;
+
     /**
      * Creates the builder.
      */
@@ -499,6 +501,18 @@ public class PebbleEngine {
       return this;
     }
 
+    /**
+     * Enable/disable treat literal numbers as BigDecimals. Default is disabled, treated as Long/Double.
+     *
+     * @param literalNumbersAsBigDecimals toggle to enable/disable literal numbers treated as
+     * BigDecimals
+     * @return This builder object
+     */
+    public Builder literalNumbersAsBigDecimals(boolean literalNumbersAsBigDecimals) {
+      this.literalNumbersAsBigDecimals = literalNumbersAsBigDecimals;
+      return this;
+    }
+
     /**
      * Enable/disable greedy matching mode for finding java method. Default is disabled. If enabled,
      * when can not find perfect method (method name, parameter length and parameter type are all
@@ -568,6 +582,7 @@ public class PebbleEngine {
 
       ParserOptions parserOptions = new ParserOptions();
       parserOptions.setLiteralDecimalTreatedAsInteger(this.literalDecimalTreatedAsInteger);
+      parserOptions.setLiteralNumbersAsBigDecimals(this.literalNumbersAsBigDecimals);
 
       EvaluationOptions evaluationOptions = new EvaluationOptions();
       evaluationOptions.setAllowUnsafeMethods(this.allowUnsafeMethods);
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBigDecimalExpression.java b/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBigDecimalExpression.java
new file mode 100644
index 00000000..37a0a44b
--- /dev/null
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBigDecimalExpression.java
@@ -0,0 +1,47 @@
+/*
+ * This file is part of Pebble.
+ *
+ * Copyright (c) 2014 by Mitchell Bösecke
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+package com.mitchellbosecke.pebble.node.expression;
+
+import com.mitchellbosecke.pebble.extension.NodeVisitor;
+import com.mitchellbosecke.pebble.template.EvaluationContextImpl;
+import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
+
+import java.math.BigDecimal;
+
+public class LiteralBigDecimalExpression implements Expression<BigDecimal> {
+
+  private final BigDecimal value;
+  private final int lineNumber;
+
+  public LiteralBigDecimalExpression(BigDecimal value, int lineNumber) {
+    this.value = value;
+    this.lineNumber = lineNumber;
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+
+  @Override
+  public BigDecimal evaluate(PebbleTemplateImpl self, EvaluationContextImpl context) {
+    return this.value;
+  }
+
+  @Override
+  public int getLineNumber() {
+    return this.lineNumber;
+  }
+
+  @Override
+  public String toString() {
+    return this.value.toString();
+  }
+
+}
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java b/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
index bcf8f9a0..5b36cc5e 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
@@ -26,6 +26,7 @@ import com.mitchellbosecke.pebble.node.expression.FilterExpression;
 import com.mitchellbosecke.pebble.node.expression.FilterInvocationExpression;
 import com.mitchellbosecke.pebble.node.expression.FunctionOrMacroInvocationExpression;
 import com.mitchellbosecke.pebble.node.expression.GetAttributeExpression;
+import com.mitchellbosecke.pebble.node.expression.LiteralBigDecimalExpression;
 import com.mitchellbosecke.pebble.node.expression.LiteralBooleanExpression;
 import com.mitchellbosecke.pebble.node.expression.LiteralDoubleExpression;
 import com.mitchellbosecke.pebble.node.expression.LiteralIntegerExpression;
@@ -41,6 +42,8 @@ import com.mitchellbosecke.pebble.node.expression.UnaryExpression;
 import com.mitchellbosecke.pebble.operator.Associativity;
 import com.mitchellbosecke.pebble.operator.BinaryOperator;
 import com.mitchellbosecke.pebble.operator.UnaryOperator;
+
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -306,14 +309,18 @@ public class ExpressionParser {
 
       case NUMBER:
         final String numberValue = token.getValue();
-        if (numberValue.contains(".")) {
-          node = new LiteralDoubleExpression(Double.valueOf(numberValue), token.getLineNumber());
+        if (this.parserOptions.isLiteralNumbersAsBigDecimals()) {
+          node = new LiteralBigDecimalExpression(new BigDecimal(numberValue), token.getLineNumber());
         } else {
-          if (this.parserOptions.isLiteralDecimalTreatedAsInteger()) {
-            node = new LiteralIntegerExpression(Integer.valueOf(numberValue),
-                token.getLineNumber());
+          if (numberValue.contains(".")) {
+            node = new LiteralDoubleExpression(Double.valueOf(numberValue), token.getLineNumber());
           } else {
-            node = new LiteralLongExpression(Long.valueOf(numberValue), token.getLineNumber());
+            if (this.parserOptions.isLiteralDecimalTreatedAsInteger()) {
+              node = new LiteralIntegerExpression(Integer.valueOf(numberValue),
+                      token.getLineNumber());
+            } else {
+              node = new LiteralLongExpression(Long.valueOf(numberValue), token.getLineNumber());
+            }
           }
         }
         this.stream.next();
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ParserOptions.java b/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ParserOptions.java
index ce7d7ae5..ec0ca278 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ParserOptions.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/parser/ParserOptions.java
@@ -9,6 +9,8 @@ public class ParserOptions {
 
   private boolean literalDecimalTreatedAsInteger;
 
+  private boolean literalNumbersAsBigDecimals;
+
   public boolean isLiteralDecimalTreatedAsInteger() {
     return literalDecimalTreatedAsInteger;
   }
@@ -17,4 +19,15 @@ public class ParserOptions {
     this.literalDecimalTreatedAsInteger = literalDecimalTreatedAsInteger;
     return this;
   }
+
+  public boolean isLiteralNumbersAsBigDecimals() {
+    return literalNumbersAsBigDecimals;
+  }
+
+  public ParserOptions setLiteralNumbersAsBigDecimals(boolean literalNumbersAsBigDecimals) {
+    this.literalNumbersAsBigDecimals = literalNumbersAsBigDecimals;
+    return this;
+  }
+
+
 }


Test patch:
diff --git a/pebble/src/test/java/com/mitchellbosecke/pebble/GetAttributeTest.java b/pebble/src/test/java/com/mitchellbosecke/pebble/GetAttributeTest.java
index ce2e338e..61c4fab0 100644
--- a/pebble/src/test/java/com/mitchellbosecke/pebble/GetAttributeTest.java
+++ b/pebble/src/test/java/com/mitchellbosecke/pebble/GetAttributeTest.java
@@ -568,6 +568,35 @@ class GetAttributeTest {
     assertEquals("hello 2 2", writer.toString());
   }
 
+  @Test
+  void testBeanMethodWithNumberLiteralsAsBigDecimals() throws PebbleException, IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().loader(new StringLoader())
+            .strictVariables(true).literalNumbersAsBigDecimals(true)
+            .build();
+
+    PebbleTemplate template = pebble.getTemplate("hello {{ 1234567890123456789012345678901234567890 }}");
+    Map<String, Object> context = new HashMap<>();
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+    assertEquals("hello 1234567890123456789012345678901234567890", writer.toString());
+  }
+
+  @Test
+  void testBeanMethodWithoutNumberLiteralsAsBigDecimals() throws PebbleException, IOException {
+    assertThrows(NumberFormatException.class, () -> {
+      PebbleEngine pebble = new PebbleEngine.Builder().loader(new StringLoader())
+              .strictVariables(true).literalNumbersAsBigDecimals(false)
+              .build();
+
+      PebbleTemplate template = pebble.getTemplate("hello {{ 1234567890123456789012345678901234567890 }}");
+      Map<String, Object> context = new HashMap<>();
+
+      Writer writer = new StringWriter();
+      template.evaluate(writer, context);
+    });
+  }
+
   @Test
   void testBeanMethodWithOverloadedArgument() throws PebbleException, IOException {
     PebbleEngine pebble = new PebbleEngine.Builder().loader(new StringLoader())