http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/EvalUtil.java
----------------------------------------------------------------------
diff --git a/src/main/java/org/apache/freemarker/core/EvalUtil.java 
b/src/main/java/org/apache/freemarker/core/EvalUtil.java
new file mode 100644
index 0000000..6889232
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/EvalUtil.java
@@ -0,0 +1,540 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+import java.util.Date;
+
+import org.apache.freemarker.core.model.TemplateBooleanModel;
+import org.apache.freemarker.core.model.TemplateCollectionModel;
+import org.apache.freemarker.core.model.TemplateDateModel;
+import org.apache.freemarker.core.model.TemplateModel;
+import org.apache.freemarker.core.model.TemplateModelException;
+import org.apache.freemarker.core.model.TemplateNumberModel;
+import org.apache.freemarker.core.model.TemplateScalarModel;
+import org.apache.freemarker.core.model.TemplateSequenceModel;
+import org.apache.freemarker.core.util.BugException;
+
+/**
+ * Internally used static utilities for evaluation expressions.
+ */
+class EvalUtil {
+    static final int CMP_OP_EQUALS = 1;
+    static final int CMP_OP_NOT_EQUALS = 2;
+    static final int CMP_OP_LESS_THAN = 3;
+    static final int CMP_OP_GREATER_THAN = 4;
+    static final int CMP_OP_LESS_THAN_EQUALS = 5;
+    static final int CMP_OP_GREATER_THAN_EQUALS = 6;
+    // If you add a new operator here, update the "compare" and 
"cmpOpToString" methods!
+    
+    // Prevents instantination.
+    private EvalUtil() { }
+    
+    /**
+     * @param expr {@code null} is allowed, but may results in less helpful 
error messages
+     * @param env {@code null} is allowed
+     */
+    static String modelToString(TemplateScalarModel model, ASTExpression expr, 
Environment env)
+    throws TemplateModelException {
+        String value = model.getAsString();
+        if (value == null) {
+            throw newModelHasStoredNullException(String.class, model, expr);
+        }
+        return value;
+    }
+    
+    /**
+     * @param expr {@code null} is allowed, but may results in less helpful 
error messages
+     */
+    static Number modelToNumber(TemplateNumberModel model, ASTExpression expr)
+        throws TemplateModelException {
+        Number value = model.getAsNumber();
+        if (value == null) throw newModelHasStoredNullException(Number.class, 
model, expr);
+        return value;
+    }
+
+    /**
+     * @param expr {@code null} is allowed, but may results in less helpful 
error messages
+     */
+    static Date modelToDate(TemplateDateModel model, ASTExpression expr)
+        throws TemplateModelException {
+        Date value = model.getAsDate();
+        if (value == null) throw newModelHasStoredNullException(Date.class, 
model, expr);
+        return value;
+    }
+    
+    /** Signals the buggy case where we have a non-null model, but it wraps a 
null. */
+    static TemplateModelException newModelHasStoredNullException(
+            Class expected, TemplateModel model, ASTExpression expr) {
+        return new _TemplateModelException(expr,
+                
_TemplateModelException.modelHasStoredNullDescription(expected, model));
+    }
+
+    /**
+     * Compares two expressions according the rules of the FTL comparator 
operators.
+     * 
+     * @param leftExp not {@code null}
+     * @param operator one of the {@code COMP_OP_...} constants, like {@link 
#CMP_OP_EQUALS}.
+     * @param operatorString can be null {@code null}; the actual operator 
used, used for more accurate error message.
+     * @param rightExp not {@code null}
+     * @param env {@code null} is tolerated, but should be avoided
+     */
+    static boolean compare(
+            ASTExpression leftExp,
+            int operator, String  operatorString,
+            ASTExpression rightExp,
+            ASTExpression defaultBlamed,
+            Environment env) throws TemplateException {
+        TemplateModel ltm = leftExp.eval(env);
+        TemplateModel rtm = rightExp.eval(env);
+        return compare(
+                ltm, leftExp,
+                operator, operatorString,
+                rtm, rightExp,
+                defaultBlamed, false,
+                false, false, false,
+                env);
+    }
+    
+    /**
+     * Compares values according the rules of the FTL comparator operators; if 
the {@link ASTExpression}-s are
+     * accessible, use {@link #compare(ASTExpression, int, String, 
ASTExpression, ASTExpression, Environment)} instead, as
+     * that gives better error messages.
+     * 
+     * @param leftValue maybe {@code null}, which will usually cause the 
appropriate {@link TemplateException}. 
+     * @param operator one of the {@code COMP_OP_...} constants, like {@link 
#CMP_OP_EQUALS}.
+     * @param rightValue maybe {@code null}, which will usually cause the 
appropriate {@link TemplateException}.
+     * @param env {@code null} is tolerated, but should be avoided
+     */
+    static boolean compare(
+            TemplateModel leftValue, int operator, TemplateModel rightValue,
+            Environment env) throws TemplateException {
+        return compare(
+                leftValue, null,
+                operator, null,
+                rightValue, null,
+                null, false,
+                false, false, false,
+                env);
+    }
+
+    /**
+     * Same as {@link #compare(TemplateModel, int, TemplateModel, 
Environment)}, but if the two types are incompatible,
+     *     they are treated as non-equal instead of throwing an exception. 
Comparing dates of different types will
+     *     still throw an exception, however.
+     */
+    static boolean compareLenient(
+            TemplateModel leftValue, int operator, TemplateModel rightValue,
+            Environment env) throws TemplateException {
+        return compare(
+                leftValue, null,
+                operator, null,
+                rightValue, null,
+                null, false,
+                true, false, false,
+                env);
+    }
+    
+    private static final String VALUE_OF_THE_COMPARISON_IS_UNKNOWN_DATE_LIKE
+            = "value of the comparison is a date-like value where "
+              + "it's not known if it's a date (no time part), time, or 
date-time, "
+              + "and thus can't be used in a comparison.";
+    
+    /**
+     * @param leftExp {@code null} is allowed, but may results in less helpful 
error messages
+     * @param operator one of the {@code COMP_OP_...} constants, like {@link 
#CMP_OP_EQUALS}.
+     * @param operatorString can be null {@code null}; the actual operator 
used, used for more accurate error message.
+     * @param rightExp {@code null} is allowed, but may results in less 
helpful error messages
+     * @param defaultBlamed {@code null} allowed; the expression to which the 
error will point to if something goes
+     *        wrong that is not specific to the left or right side expression, 
or if that expression is {@code null}.
+     * @param typeMismatchMeansNotEqual If the two types are incompatible, 
they are treated as non-equal instead
+     *     of throwing an exception. Comparing dates of different types will 
still throw an exception, however. 
+     * @param leftNullReturnsFalse if {@code true}, a {@code null} left value 
will not cause exception, but make the
+     *     expression {@code false}.  
+     * @param rightNullReturnsFalse if {@code true}, a {@code null} right 
value will not cause exception, but make the
+     *     expression {@code false}.  
+     */
+    static boolean compare(
+            TemplateModel leftValue, ASTExpression leftExp,
+            int operator, String operatorString,
+            TemplateModel rightValue, ASTExpression rightExp,
+            ASTExpression defaultBlamed, boolean quoteOperandsInErrors,
+            boolean typeMismatchMeansNotEqual,
+            boolean leftNullReturnsFalse, boolean rightNullReturnsFalse,
+            Environment env) throws TemplateException {
+        if (leftValue == null) {
+            if (leftNullReturnsFalse) { 
+                return false;
+            } else {
+                if (leftExp != null) {
+                    throw InvalidReferenceException.getInstance(leftExp, env);
+                } else {
+                    throw new _MiscTemplateException(defaultBlamed, env, 
+                                "The left operand of the comparison was 
undefined or null.");
+                }
+            }
+        }
+
+        if (rightValue == null) {
+            if (rightNullReturnsFalse) { 
+                return false;
+            } else {
+                if (rightExp != null) {
+                    throw InvalidReferenceException.getInstance(rightExp, env);
+                } else {
+                    throw new _MiscTemplateException(defaultBlamed, env,
+                                "The right operand of the comparison was 
undefined or null.");
+                }
+            }
+        }
+
+        final int cmpResult;
+        if (leftValue instanceof TemplateNumberModel && rightValue instanceof 
TemplateNumberModel) {
+            Number leftNum = EvalUtil.modelToNumber((TemplateNumberModel) 
leftValue, leftExp);
+            Number rightNum = EvalUtil.modelToNumber((TemplateNumberModel) 
rightValue, rightExp);
+            ArithmeticEngine ae =
+                    env != null
+                        ? env.getArithmeticEngine()
+                        : (leftExp != null
+                            ? leftExp.getTemplate().getArithmeticEngine()
+                            : ArithmeticEngine.BIGDECIMAL_ENGINE);
+            try {
+                cmpResult = ae.compareNumbers(leftNum, rightNum);
+            } catch (RuntimeException e) {
+                throw new _MiscTemplateException(defaultBlamed, e, env, new 
Object[]
+                        { "Unexpected error while comparing two numbers: ", e 
});
+            }
+        } else if (leftValue instanceof TemplateDateModel && rightValue 
instanceof TemplateDateModel) {
+            TemplateDateModel leftDateModel = (TemplateDateModel) leftValue;
+            TemplateDateModel rightDateModel = (TemplateDateModel) rightValue;
+            
+            int leftDateType = leftDateModel.getDateType();
+            int rightDateType = rightDateModel.getDateType();
+            
+            if (leftDateType == TemplateDateModel.UNKNOWN || rightDateType == 
TemplateDateModel.UNKNOWN) {
+                String sideName;
+                ASTExpression sideExp;
+                if (leftDateType == TemplateDateModel.UNKNOWN) {
+                    sideName = "left";
+                    sideExp = leftExp;
+                } else {
+                    sideName = "right";
+                    sideExp = rightExp;
+                }
+                
+                throw new _MiscTemplateException(sideExp != null ? sideExp : 
defaultBlamed, env,
+                        "The ", sideName, " ", 
VALUE_OF_THE_COMPARISON_IS_UNKNOWN_DATE_LIKE);
+            }
+            
+            if (leftDateType != rightDateType) {
+                ;
+                throw new _MiscTemplateException(defaultBlamed, env,
+                        "Can't compare dates of different types. Left date 
type is ",
+                        TemplateDateModel.TYPE_NAMES.get(leftDateType), ", 
right date type is ",
+                        TemplateDateModel.TYPE_NAMES.get(rightDateType), ".");
+            }
+
+            Date leftDate = EvalUtil.modelToDate(leftDateModel, leftExp);
+            Date rightDate = EvalUtil.modelToDate(rightDateModel, rightExp);
+            cmpResult = leftDate.compareTo(rightDate);
+        } else if (leftValue instanceof TemplateScalarModel && rightValue 
instanceof TemplateScalarModel) {
+            if (operator != CMP_OP_EQUALS && operator != CMP_OP_NOT_EQUALS) {
+                throw new _MiscTemplateException(defaultBlamed, env,
+                        "Can't use operator \"", cmpOpToString(operator, 
operatorString), "\" on string values.");
+            }
+            String leftString = EvalUtil.modelToString((TemplateScalarModel) 
leftValue, leftExp, env);
+            String rightString = EvalUtil.modelToString((TemplateScalarModel) 
rightValue, rightExp, env);
+            // FIXME NBC: Don't use the Collator here. That's locale-specific, 
but ==/!= should not be.
+            cmpResult = env.getCollator().compare(leftString, rightString);
+        } else if (leftValue instanceof TemplateBooleanModel && rightValue 
instanceof TemplateBooleanModel) {
+            if (operator != CMP_OP_EQUALS && operator != CMP_OP_NOT_EQUALS) {
+                throw new _MiscTemplateException(defaultBlamed, env,
+                        "Can't use operator \"", cmpOpToString(operator, 
operatorString), "\" on boolean values.");
+            }
+            boolean leftBool = ((TemplateBooleanModel) 
leftValue).getAsBoolean();
+            boolean rightBool = ((TemplateBooleanModel) 
rightValue).getAsBoolean();
+            cmpResult = (leftBool ? 1 : 0) - (rightBool ? 1 : 0);
+        } else {
+            if (typeMismatchMeansNotEqual) {
+                if (operator == CMP_OP_EQUALS) {
+                    return false;
+                } else if (operator == CMP_OP_NOT_EQUALS) {
+                    return true;
+                }
+                // Falls through
+            }
+            throw new _MiscTemplateException(defaultBlamed, env,
+                    "Can't compare values of these types. ",
+                    "Allowed comparisons are between two numbers, two strings, 
two dates, or two booleans.\n",
+                    "Left hand operand ",
+                    (quoteOperandsInErrors && leftExp != null
+                            ? new Object[] { "(", new 
_DelayedGetCanonicalForm(leftExp), ") value " }
+                            : ""),
+                    "is ", new _DelayedAOrAn(new 
_DelayedFTLTypeDescription(leftValue)), ".\n",
+                    "Right hand operand ",
+                    (quoteOperandsInErrors && rightExp != null
+                            ? new Object[] { "(", new 
_DelayedGetCanonicalForm(rightExp), ") value " }
+                            : ""),
+                    "is ", new _DelayedAOrAn(new 
_DelayedFTLTypeDescription(rightValue)),
+                    ".");
+        }
+
+        switch (operator) {
+            case CMP_OP_EQUALS: return cmpResult == 0;
+            case CMP_OP_NOT_EQUALS: return cmpResult != 0;
+            case CMP_OP_LESS_THAN: return cmpResult < 0;
+            case CMP_OP_GREATER_THAN: return cmpResult > 0;
+            case CMP_OP_LESS_THAN_EQUALS: return cmpResult <= 0;
+            case CMP_OP_GREATER_THAN_EQUALS: return cmpResult >= 0;
+            default: throw new BugException("Unsupported comparator operator 
code: " + operator);
+        }
+    }
+
+    private static String cmpOpToString(int operator, String operatorString) {
+        if (operatorString != null) {
+            return operatorString;
+        } else {
+            switch (operator) {
+                case CMP_OP_EQUALS: return "equals";
+                case CMP_OP_NOT_EQUALS: return "not-equals";
+                case CMP_OP_LESS_THAN: return "less-than";
+                case CMP_OP_GREATER_THAN: return "greater-than";
+                case CMP_OP_LESS_THAN_EQUALS: return "less-than-equals";
+                case CMP_OP_GREATER_THAN_EQUALS: return "greater-than-equals";
+                default: return "???";
+            }
+        }
+    }
+
+    /**
+     * Converts a value to plain text {@link String}, or a {@link 
TemplateMarkupOutputModel} if that's what the
+     * {@link TemplateValueFormat} involved produces.
+     * 
+     * @param seqTip
+     *            Tip to display if the value type is not coercable, but it's 
sequence or collection.
+     * 
+     * @return Never {@code null}
+     * @throws TemplateException 
+     */
+    static Object coerceModelToStringOrMarkup(TemplateModel tm, ASTExpression 
exp, String seqTip, Environment env)
+            throws TemplateException {
+        return coerceModelToStringOrMarkup(tm, exp, false, seqTip, env);
+    }
+    
+    /**
+     * @return {@code null} if the {@code returnNullOnNonCoercableType} 
parameter is {@code true}, and the coercion is
+     *         not possible, because of the type is not right for it.
+     * 
+     * @see #coerceModelToStringOrMarkup(TemplateModel, ASTExpression, String, 
Environment)
+     */
+    static Object coerceModelToStringOrMarkup(
+            TemplateModel tm, ASTExpression exp, boolean 
returnNullOnNonCoercableType, String seqTip, Environment env)
+            throws TemplateException {
+        if (tm instanceof TemplateNumberModel) {
+            TemplateNumberModel tnm = (TemplateNumberModel) tm; 
+            TemplateNumberFormat format = env.getTemplateNumberFormat(exp, 
false);
+            try {
+                return assertFormatResultNotNull(format.format(tnm));
+            } catch (TemplateValueFormatException e) {
+                throw MessageUtil.newCantFormatNumberException(format, exp, e, 
false);
+            }
+        } else if (tm instanceof TemplateDateModel) {
+            TemplateDateModel tdm = (TemplateDateModel) tm;
+            TemplateDateFormat format = env.getTemplateDateFormat(tdm, exp, 
false);
+            try {
+                return assertFormatResultNotNull(format.format(tdm));
+            } catch (TemplateValueFormatException e) {
+                throw MessageUtil.newCantFormatDateException(format, exp, e, 
false);
+            }
+        } else if (tm instanceof TemplateMarkupOutputModel) {
+            return tm;
+        } else { 
+            return coerceModelToTextualCommon(tm, exp, seqTip, true, 
returnNullOnNonCoercableType, env);
+        }
+    }
+
+    /**
+     * Like {@link #coerceModelToStringOrMarkup(TemplateModel, ASTExpression, 
String, Environment)}, but gives error
+     * if the result is markup. This is what you normally use where markup 
results can't be used.
+     *
+     * @param seqTip
+     *            Tip to display if the value type is not coercable, but it's 
sequence or collection.
+     * 
+     * @return Never {@code null}
+     */
+    static String coerceModelToStringOrUnsupportedMarkup(
+            TemplateModel tm, ASTExpression exp, String seqTip, Environment 
env)
+            throws TemplateException {
+        if (tm instanceof TemplateNumberModel) {
+            TemplateNumberModel tnm = (TemplateNumberModel) tm; 
+            TemplateNumberFormat format = env.getTemplateNumberFormat(exp, 
false);
+            try {
+                return ensureFormatResultString(format.format(tnm), exp, env);
+            } catch (TemplateValueFormatException e) {
+                throw MessageUtil.newCantFormatNumberException(format, exp, e, 
false);
+            }
+        } else if (tm instanceof TemplateDateModel) {
+            TemplateDateModel tdm = (TemplateDateModel) tm;
+            TemplateDateFormat format = env.getTemplateDateFormat(tdm, exp, 
false);
+            try {
+                return ensureFormatResultString(format.format(tdm), exp, env);
+            } catch (TemplateValueFormatException e) {
+                throw MessageUtil.newCantFormatDateException(format, exp, e, 
false);
+            }
+        } else { 
+            return coerceModelToTextualCommon(tm, exp, seqTip, false, false, 
env);
+        }
+    }
+
+    /**
+     * Converts a value to plain text {@link String}, even if the {@link 
TemplateValueFormat} involved normally produces
+     * markup. This should be used rarely, where the user clearly intend to 
use the plain text variant of the format.
+     * 
+     * @param seqTip
+     *            Tip to display if the value type is not coercable, but it's 
sequence or collection.
+     * 
+     * @return Never {@code null}
+     */
+    static String coerceModelToPlainText(TemplateModel tm, ASTExpression exp, 
String seqTip,
+            Environment env) throws TemplateException {
+        if (tm instanceof TemplateNumberModel) {
+            return 
assertFormatResultNotNull(env.formatNumberToPlainText((TemplateNumberModel) tm, 
exp, false));
+        } else if (tm instanceof TemplateDateModel) {
+            return 
assertFormatResultNotNull(env.formatDateToPlainText((TemplateDateModel) tm, 
exp, false));
+        } else {
+            return coerceModelToTextualCommon(tm, exp, seqTip, false, false, 
env);
+        }
+    }
+
+    /**
+     * @param tm
+     *            If {@code null} that's an exception
+     * 
+     * @param supportsTOM
+     *            Whether the caller {@code coerceModelTo...} method could 
handle a {@link TemplateMarkupOutputModel}.
+     *            
+     * @return Never {@code null}
+     */
+    private static String coerceModelToTextualCommon(
+            TemplateModel tm, ASTExpression exp, String seqHint, boolean 
supportsTOM, boolean returnNullOnNonCoercableType,
+            Environment env)
+            throws TemplateModelException, InvalidReferenceException, 
TemplateException,
+                    NonStringOrTemplateOutputException, NonStringException {
+        if (tm instanceof TemplateScalarModel) {
+            return modelToString((TemplateScalarModel) tm, exp, env);
+        } else if (tm == null) {
+            if (exp != null) {
+                throw InvalidReferenceException.getInstance(exp, env);
+            } else {
+                throw new InvalidReferenceException(
+                        "Null/missing value (no more informatoin avilable)",
+                        env);
+            }
+        } else if (tm instanceof TemplateBooleanModel) {
+            // [FM3] This should be before TemplateScalarModel, but automatic 
boolean-to-string is only non-error since
+            // 2.3.20, so to keep backward compatibility we couldn't insert 
this before TemplateScalarModel.
+            boolean booleanValue = ((TemplateBooleanModel) tm).getAsBoolean();
+            return env.formatBoolean(booleanValue, false);
+        } else {
+            if (returnNullOnNonCoercableType) {
+                return null;
+            }
+            if (seqHint != null && (tm instanceof TemplateSequenceModel || tm 
instanceof TemplateCollectionModel)) {
+                if (supportsTOM) {
+                    throw new NonStringOrTemplateOutputException(exp, tm, 
seqHint, env);
+                } else {
+                    throw new NonStringException(exp, tm, seqHint, env);
+                }
+            } else {
+                if (supportsTOM) {
+                    throw new NonStringOrTemplateOutputException(exp, tm, env);
+                } else {
+                    throw new NonStringException(exp, tm, env);
+                }
+            }
+        }
+    }
+
+    private static String ensureFormatResultString(Object formatResult, 
ASTExpression exp, Environment env)
+            throws NonStringException {
+        if (formatResult instanceof String) { 
+            return (String) formatResult;
+        }
+        
+        assertFormatResultNotNull(formatResult);
+        
+        TemplateMarkupOutputModel mo = (TemplateMarkupOutputModel) 
formatResult;
+        _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
+                "Value was formatted to convert it to string, but the result 
was markup of ouput format ",
+                new _DelayedJQuote(mo.getOutputFormat()), ".")
+                .tip("Use value?string to force formatting to plain text.")
+                .blame(exp);
+        throw new NonStringException(null, desc);
+    }
+
+    static String assertFormatResultNotNull(String r) {
+        if (r != null) {
+            return r;
+        }
+        throw new NullPointerException("TemplateValueFormatter result can't be 
null");
+    }
+
+    static Object assertFormatResultNotNull(Object r) {
+        if (r != null) {
+            return r;
+        }
+        throw new NullPointerException("TemplateValueFormatter result can't be 
null");
+    }
+
+    static TemplateMarkupOutputModel concatMarkupOutputs(ASTNode parent, 
TemplateMarkupOutputModel leftMO,
+            TemplateMarkupOutputModel rightMO) throws TemplateException {
+        MarkupOutputFormat leftOF = leftMO.getOutputFormat();
+        MarkupOutputFormat rightOF = rightMO.getOutputFormat();
+        if (rightOF != leftOF) {
+            String rightPT;
+            String leftPT;
+            if ((rightPT = rightOF.getSourcePlainText(rightMO)) != null) {
+                return leftOF.concat(leftMO, 
leftOF.fromPlainTextByEscaping(rightPT));
+            } else if ((leftPT = leftOF.getSourcePlainText(leftMO)) != null) {
+                return rightOF.concat(rightOF.fromPlainTextByEscaping(leftPT), 
rightMO);
+            } else {
+                Object[] message = { "Concatenation left hand operand is in ", 
new _DelayedToString(leftOF),
+                        " format, while the right hand operand is in ", new 
_DelayedToString(rightOF),
+                        ". Conversion to common format wasn't possible." };
+                if (parent instanceof ASTExpression) {
+                    throw new _MiscTemplateException((ASTExpression) parent, 
message);
+                } else {
+                    throw new _MiscTemplateException(message);
+                }
+            }
+        } else {
+            return leftOF.concat(leftMO, rightMO);
+        }
+    }
+
+    /**
+     * Returns an {@link ArithmeticEngine} even if {@code env} is {@code 
null}, because we are in parsing phase.
+     */
+    static ArithmeticEngine getArithmeticEngine(Environment env, ASTNode tObj) 
{
+        return env != null
+                ? env.getArithmeticEngine()
+                : 
tObj.getTemplate().getParserConfiguration().getArithmeticEngine();
+    }
+    
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/ExtendedDecimalFormatParser.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/ExtendedDecimalFormatParser.java 
b/src/main/java/org/apache/freemarker/core/ExtendedDecimalFormatParser.java
new file mode 100644
index 0000000..62651b6
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/ExtendedDecimalFormatParser.java
@@ -0,0 +1,525 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Currency;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.freemarker.core.util._StringUtil;
+
+class ExtendedDecimalFormatParser {
+    
+    private static final String PARAM_ROUNDING_MODE = "roundingMode";
+    private static final String PARAM_MULTIPIER = "multipier";
+    private static final String PARAM_DECIMAL_SEPARATOR = "decimalSeparator";
+    private static final String PARAM_MONETARY_DECIMAL_SEPARATOR = 
"monetaryDecimalSeparator";
+    private static final String PARAM_GROUP_SEPARATOR = "groupingSeparator";
+    private static final String PARAM_EXPONENT_SEPARATOR = "exponentSeparator";
+    private static final String PARAM_MINUS_SIGN = "minusSign";
+    private static final String PARAM_INFINITY = "infinity";
+    private static final String PARAM_NAN = "nan";
+    private static final String PARAM_PERCENT = "percent";
+    private static final String PARAM_PER_MILL = "perMill";
+    private static final String PARAM_ZERO_DIGIT = "zeroDigit";
+    private static final String PARAM_CURRENCY_CODE = "currencyCode";
+    private static final String PARAM_CURRENCY_SYMBOL = "currencySymbol";
+
+    private static final String PARAM_VALUE_RND_UP = "up";
+    private static final String PARAM_VALUE_RND_DOWN = "down";
+    private static final String PARAM_VALUE_RND_CEILING = "ceiling";
+    private static final String PARAM_VALUE_RND_FLOOR = "floor";
+    private static final String PARAM_VALUE_RND_HALF_DOWN = "halfDown";
+    private static final String PARAM_VALUE_RND_HALF_EVEN = "halfEven";
+    private static final String PARAM_VALUE_RND_HALF_UP = "halfUp";
+    private static final String PARAM_VALUE_RND_UNNECESSARY = "unnecessary";
+    
+    private static final HashMap<String, ? extends ParameterHandler> 
PARAM_HANDLERS;
+    static {
+        HashMap<String, ParameterHandler> m = new HashMap<>();
+        m.put(PARAM_ROUNDING_MODE, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                RoundingMode parsedValue;
+                if (value.equals(PARAM_VALUE_RND_UP)) {
+                    parsedValue = RoundingMode.UP;
+                } else if (value.equals(PARAM_VALUE_RND_DOWN)) {
+                    parsedValue = RoundingMode.DOWN;
+                } else if (value.equals(PARAM_VALUE_RND_CEILING)) {
+                    parsedValue = RoundingMode.CEILING;
+                } else if (value.equals(PARAM_VALUE_RND_FLOOR)) {
+                    parsedValue = RoundingMode.FLOOR;
+                } else if (value.equals(PARAM_VALUE_RND_HALF_DOWN)) {
+                    parsedValue = RoundingMode.HALF_DOWN;
+                } else if (value.equals(PARAM_VALUE_RND_HALF_EVEN)) {
+                    parsedValue = RoundingMode.HALF_EVEN;
+                } else if (value.equals(PARAM_VALUE_RND_HALF_UP)) {
+                    parsedValue = RoundingMode.HALF_UP;
+                } else if (value.equals(PARAM_VALUE_RND_UNNECESSARY)) {
+                    parsedValue = RoundingMode.UNNECESSARY;
+                } else {
+                    throw new InvalidParameterValueException("Should be one 
of: u, d, c, f, hd, he, hu, un");
+                }
+
+                parser.roundingMode = parsedValue;
+            }
+        });
+        m.put(PARAM_MULTIPIER, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                try {
+                    parser.multipier = Integer.valueOf(value);
+                } catch (NumberFormatException e) {
+                    throw new InvalidParameterValueException("Malformed 
integer.");
+                }
+            }
+        });
+        m.put(PARAM_DECIMAL_SEPARATOR, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setDecimalSeparator(value.charAt(0));
+            }
+        });
+        m.put(PARAM_MONETARY_DECIMAL_SEPARATOR, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setMonetaryDecimalSeparator(value.charAt(0));
+            }
+        });
+        m.put(PARAM_GROUP_SEPARATOR, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setGroupingSeparator(value.charAt(0));
+            }
+        });
+        m.put(PARAM_EXPONENT_SEPARATOR, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                parser.symbols.setExponentSeparator(value);
+            }
+        });
+        m.put(PARAM_MINUS_SIGN, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setMinusSign(value.charAt(0));
+            }
+        });
+        m.put(PARAM_INFINITY, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                parser.symbols.setInfinity(value);
+            }
+        });
+        m.put(PARAM_NAN, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                parser.symbols.setNaN(value);
+            }
+        });
+        m.put(PARAM_PERCENT, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setPercent(value.charAt(0));
+            }
+        });
+        m.put(PARAM_PER_MILL, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setPerMill(value.charAt(0));
+            }
+        });
+        m.put(PARAM_ZERO_DIGIT, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                if (value.length() != 1) {
+                    throw new InvalidParameterValueException("Must contain 
exactly 1 character.");
+                }
+                parser.symbols.setZeroDigit(value.charAt(0));
+            }
+        });
+        m.put(PARAM_CURRENCY_CODE, new ParameterHandler() {
+            @Override
+            public void handle(ExtendedDecimalFormatParser parser, String 
value)
+                    throws InvalidParameterValueException {
+                Currency currency;
+                try {
+                    currency = Currency.getInstance(value);
+                } catch (IllegalArgumentException e) {
+                    throw new InvalidParameterValueException("Not a known ISO 
4217 code.");
+                }
+                parser.symbols.setCurrency(currency);
+            }
+        });
+        PARAM_HANDLERS = m;
+    }
+
+    private static final String SNIP_MARK = "[...]";
+    private static final int MAX_QUOTATION_LENGTH = 10; // Must be more than 
SNIP_MARK.length!
+
+    private final String src;
+    private int pos = 0;
+
+    private final DecimalFormatSymbols symbols;
+    private RoundingMode roundingMode;
+    private Integer multipier;
+
+    static DecimalFormat parse(String formatString, Locale locale) throws 
ParseException {
+        return new ExtendedDecimalFormatParser(formatString, locale).parse();
+    }
+
+    private DecimalFormat parse() throws ParseException {
+        String stdPattern = fetchStandardPattern();
+        skipWS();
+        parseFormatStringExtension();
+
+        DecimalFormat decimalFormat;
+        try {
+            decimalFormat = new DecimalFormat(stdPattern, symbols);
+        } catch (IllegalArgumentException e) {
+            ParseException pe = new ParseException(e.getMessage(), 0);
+            if (e.getCause() != null) {
+                try {
+                    e.initCause(e.getCause());
+                } catch (Exception e2) {
+                    // Supress
+                }
+            }
+            throw pe;
+        }
+
+        if (roundingMode != null) {
+            decimalFormat.setRoundingMode(roundingMode);
+        }
+
+        if (multipier != null) {
+            decimalFormat.setMultiplier(multipier.intValue());
+        }
+
+        return decimalFormat;
+    }
+
+    private void parseFormatStringExtension() throws ParseException {
+        int ln = src.length();
+
+        if (pos == ln) {
+            return;
+        }
+
+        String currencySymbol = null;  // Exceptional, as must be applied 
after "currency code"
+        fetchParamters: do {
+            int namePos = pos;
+            String name = fetchName();
+            if (name == null) {
+                throw newExpectedSgParseException("name");
+            }
+
+            skipWS();
+
+            if (!fetchChar('=')) {
+                throw newExpectedSgParseException("\"=\"");
+            }
+
+            skipWS();
+
+            int valuePos = pos;
+            String value = fetchValue();
+            if (value == null) {
+                throw newExpectedSgParseException("value");
+            }
+            int paramEndPos = pos;
+
+            ParameterHandler handler = PARAM_HANDLERS.get(name);
+            if (handler == null) {
+                if (name.equals(PARAM_CURRENCY_SYMBOL)) {
+                    currencySymbol = value;
+                } else {
+                    throw newUnknownParameterException(name, namePos);
+                }
+            } else {
+                try {
+                    handler.handle(this, value);
+                } catch (InvalidParameterValueException e) {
+                    throw newInvalidParameterValueException(name, value, 
valuePos, e);
+                }
+            }
+
+            skipWS();
+
+            // Optional comma
+            if (fetchChar(',')) {
+                skipWS();
+            } else {
+                if (pos == ln) {
+                    break fetchParamters;
+                }
+                if (pos == paramEndPos) {
+                    throw newExpectedSgParseException("parameter separator 
whitespace or comma");
+                }
+            }
+        } while (true);
+        
+        // This is brought out to here to ensure that it's applied after 
"currency code":
+        if (currencySymbol != null) {
+            symbols.setCurrencySymbol(currencySymbol);
+        }
+    }
+
+    private ParseException newInvalidParameterValueException(String name, 
String value, int valuePos,
+            InvalidParameterValueException e) {
+        return new java.text.ParseException(
+                _StringUtil.jQuote(value) + " is an invalid value for the \"" 
+ name + "\" parameter: "
+                + e.message,
+                valuePos);
+    }
+
+    private ParseException newUnknownParameterException(String name, int 
namePos) throws ParseException {
+        StringBuilder sb = new StringBuilder(128);
+        sb.append("Unsupported parameter name, 
").append(_StringUtil.jQuote(name));
+        sb.append(". The supported names are: ");
+        Set<String> legalNames = PARAM_HANDLERS.keySet();
+        String[] legalNameArr = legalNames.toArray(new 
String[legalNames.size()]);
+        Arrays.sort(legalNameArr);
+        for (int i = 0; i < legalNameArr.length; i++) {
+            if (i != 0) {
+                sb.append(", ");
+            }
+            sb.append(legalNameArr[i]);
+        }
+        return new java.text.ParseException(sb.toString(), namePos);
+    }
+
+    private void skipWS() {
+        int ln = src.length();
+        while (pos < ln && isWS(src.charAt(pos))) {
+            pos++;
+        }
+    }
+
+    private boolean fetchChar(char fetchedChar) {
+        if (pos < src.length() && src.charAt(pos) == fetchedChar) {
+            pos++;
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    private boolean isWS(char c) {
+        return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == 
'\u00A0';
+    }
+
+    private String fetchName() throws ParseException {
+        int ln = src.length();
+        int startPos = pos;
+        boolean firstChar = true;
+        scanUntilEnd: while (pos < ln) {
+            char c = src.charAt(pos);
+            if (firstChar) {
+                if (!Character.isJavaIdentifierStart(c)) {
+                    break scanUntilEnd;
+                }
+                firstChar = false;
+            } else if (!Character.isJavaIdentifierPart(c)) {
+                break scanUntilEnd;
+            }
+            pos++;
+        }
+        return !firstChar ? src.substring(startPos, pos) : null;
+    }
+
+    private String fetchValue() throws ParseException {
+        int ln = src.length();
+        int startPos = pos;
+        char openedQuot = 0;
+        boolean needsUnescaping = false;
+        scanUntilEnd: while (pos < ln) {
+            char c = src.charAt(pos);
+            if (c == '\'' || c == '"') {
+                if (openedQuot == 0) {
+                    if (startPos != pos) {
+                        throw new java.text.ParseException(
+                                "The " + c + " character can only be used for 
quoting values, "
+                                        + "but it was in the middle of an 
non-quoted value.",
+                                pos);
+                    }
+                    openedQuot = c;
+                } else if (c == openedQuot) {
+                    if (pos + 1 < ln && src.charAt(pos + 1) == openedQuot) {
+                        pos++; // skip doubled quote (escaping)
+                        needsUnescaping = true;
+                    } else {
+                        String str = src.substring(startPos + 1, pos);
+                        pos++;
+                        return needsUnescaping ? unescape(str, openedQuot) : 
str;
+                    }
+                }
+            } else {
+                if (openedQuot == 0 && !Character.isJavaIdentifierPart(c)) {
+                    break scanUntilEnd;
+                }
+            }
+            pos++;
+        } // while
+        if (openedQuot != 0) {
+            throw new java.text.ParseException(
+                    "The " + openedQuot 
+                    + " quotation wasn't closed when the end of the source was 
reached.",
+                    pos);
+        }
+        return startPos == pos ? null : src.substring(startPos, pos);
+    }
+
+    private String unescape(String s, char openedQuot) {
+        return openedQuot == '\'' ? _StringUtil.replace(s, "\'\'", "\'") : 
_StringUtil.replace(s, "\"\"", "\"");
+    }
+
+    private String fetchStandardPattern() {
+        int pos = this.pos;
+        int ln = src.length();
+        int semicolonCnt = 0;
+        boolean quotedMode = false;
+        findStdPartEnd: while (pos < ln) {
+            char c = src.charAt(pos);
+            if (c == ';' && !quotedMode) {
+                semicolonCnt++;
+                if (semicolonCnt == 2) {
+                    break findStdPartEnd;
+                }
+            } else if (c == '\'') {
+                if (quotedMode) {
+                    if (pos + 1 < ln && src.charAt(pos + 1) == '\'') {
+                        // Skips "''" used for escaping "'"
+                        pos++;
+                    } else {
+                        quotedMode = false;
+                    }
+                } else {
+                    quotedMode = true;
+                }
+            }
+            pos++;
+        }
+
+        String stdFormatStr;
+        if (semicolonCnt < 2) { // We have a standard DecimalFormat string
+            // Note that "0.0;" and "0.0" gives the same result with 
DecimalFormat, so we leave a ';' there
+            stdFormatStr = src;
+        } else { // `pos` points to the 2nd ';'
+            int stdEndPos = pos;
+            if (src.charAt(pos - 1) == ';') { // we have a ";;"
+                // Note that ";;" is illegal in DecimalFormat, so this is 
backward compatible.
+                stdEndPos--;
+            }
+            stdFormatStr = src.substring(0, stdEndPos);
+        }
+
+        if (pos < ln) {
+            pos++; // Skips closing ';'
+        }
+        this.pos = pos;
+
+        return stdFormatStr;
+    }
+
+    private ExtendedDecimalFormatParser(String formatString, Locale locale) {
+        src = formatString;
+        symbols = new DecimalFormatSymbols(locale);
+    }
+
+    private ParseException newExpectedSgParseException(String expectedThing) {
+        String quotation;
+
+        // Ignore trailing WS when calculating the length:
+        int i = src.length() - 1;
+        while (i >= 0 && Character.isWhitespace(src.charAt(i))) {
+            i--;
+        }
+        int ln = i + 1;
+
+        if (pos < ln) {
+            int qEndPos = pos + MAX_QUOTATION_LENGTH;
+            if (qEndPos >= ln) {
+                quotation = src.substring(pos, ln);
+            } else {
+                quotation = src.substring(pos, qEndPos - SNIP_MARK.length()) + 
SNIP_MARK;
+            }
+        } else {
+            quotation = null;
+        }
+
+        return new ParseException(
+                "Expected a(n) " + expectedThing + " at position " + pos + " 
(0-based), but "
+                        + (quotation == null ? "reached the end of the input." 
: "found: " + quotation),
+                pos);
+    }
+
+    private interface ParameterHandler {
+
+        void handle(ExtendedDecimalFormatParser parser, String value)
+                throws InvalidParameterValueException;
+
+    }
+
+    private static class InvalidParameterValueException extends Exception {
+
+        private final String message;
+
+        public InvalidParameterValueException(String message) {
+            this.message = message;
+        }
+
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/HTMLOutputFormat.java
----------------------------------------------------------------------
diff --git a/src/main/java/org/apache/freemarker/core/HTMLOutputFormat.java 
b/src/main/java/org/apache/freemarker/core/HTMLOutputFormat.java
new file mode 100644
index 0000000..5d89d6d
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/HTMLOutputFormat.java
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.apache.freemarker.core.model.TemplateModelException;
+import org.apache.freemarker.core.util._StringUtil;
+
+/**
+ * Represents the HTML output format (MIME type "text/html", name "HTML"). 
This format escapes by default (via
+ * {@link _StringUtil#XHTMLEnc(String)}). The {@code ?html}, {@code ?xhtml} 
and {@code ?xml} built-ins silently bypass
+ * template output values of the type produced by this output format ({@link 
TemplateHTMLOutputModel}).
+ * 
+ * @since 2.3.24
+ */
+public final class HTMLOutputFormat extends 
CommonMarkupOutputFormat<TemplateHTMLOutputModel> {
+
+    /**
+     * The only instance (singleton) of this {@link OutputFormat}.
+     */
+    public static final HTMLOutputFormat INSTANCE = new HTMLOutputFormat();
+    
+    private HTMLOutputFormat() {
+        // Only to decrease visibility
+    }
+    
+    @Override
+    public String getName() {
+        return "HTML";
+    }
+
+    @Override
+    public String getMimeType() {
+        return "text/html";
+    }
+
+    @Override
+    public void output(String textToEsc, Writer out) throws IOException, 
TemplateModelException {
+        _StringUtil.XHTMLEnc(textToEsc, out);
+    }
+
+    @Override
+    public String escapePlainText(String plainTextContent) {
+        return _StringUtil.XHTMLEnc(plainTextContent);
+    }
+
+    @Override
+    public boolean isLegacyBuiltInBypassed(String builtInName) {
+        return builtInName.equals("html") || builtInName.equals("xml") || 
builtInName.equals("xhtml");
+    }
+
+    @Override
+    protected TemplateHTMLOutputModel newTemplateMarkupOutputModel(String 
plainTextContent, String markupContent) {
+        return new TemplateHTMLOutputModel(plainTextContent, markupContent);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormat.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormat.java 
b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormat.java
new file mode 100644
index 0000000..ca6ec02
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormat.java
@@ -0,0 +1,264 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+import java.util.Date;
+import java.util.TimeZone;
+
+import org.apache.freemarker.core.model.TemplateDateModel;
+import org.apache.freemarker.core.model.TemplateModelException;
+import org.apache.freemarker.core.util.BugException;
+import org.apache.freemarker.core.util._DateUtil;
+import org.apache.freemarker.core.util._StringUtil;
+import org.apache.freemarker.core.util._DateUtil.CalendarFieldsToDateConverter;
+import org.apache.freemarker.core.util._DateUtil.DateParseException;
+import org.apache.freemarker.core.util._DateUtil.DateToISO8601CalendarFactory;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+abstract class ISOLikeTemplateDateFormat  extends TemplateDateFormat {
+    
+    private static final String XS_LESS_THAN_SECONDS_ACCURACY_ERROR_MESSAGE
+            = "Less than seconds accuracy isn't allowed by the XML Schema 
format";
+    private final ISOLikeTemplateDateFormatFactory factory;
+    private final Environment env;
+    protected final int dateType;
+    protected final boolean zonelessInput;
+    protected final TimeZone timeZone;
+    protected final Boolean forceUTC;
+    protected final Boolean showZoneOffset;
+    protected final int accuracy;
+
+    /**
+     * @param formatString The value of the ..._format setting, like "iso nz".
+     * @param parsingStart The index of the char in the {@code settingValue} 
that directly after the prefix that has
+     *     indicated the exact formatter class (like "iso" or "xs") 
+     */
+    public ISOLikeTemplateDateFormat(
+            final String formatString, int parsingStart,
+            int dateType, boolean zonelessInput,
+            TimeZone timeZone,
+            ISOLikeTemplateDateFormatFactory factory, Environment env)
+            throws InvalidFormatParametersException, 
UnknownDateTypeFormattingUnsupportedException {
+        this.factory = factory;
+        this.env = env;
+        if (dateType == TemplateDateModel.UNKNOWN) {
+            throw new UnknownDateTypeFormattingUnsupportedException();
+        }
+        
+        this.dateType = dateType;
+        this.zonelessInput = zonelessInput;
+        
+        final int ln = formatString.length();
+        boolean afterSeparator = false;
+        int i = parsingStart;
+        int accuracy = _DateUtil.ACCURACY_MILLISECONDS;
+        Boolean showZoneOffset = null;
+        Boolean forceUTC = Boolean.FALSE;
+        while (i < ln) {
+            final char c = formatString.charAt(i++);
+            if (c == '_' || c == ' ') {
+                afterSeparator = true;
+            } else {
+                if (!afterSeparator) {
+                    throw new InvalidFormatParametersException(
+                            "Missing space or \"_\" before \"" + c + "\" (at 
char pos. " + i + ").");
+                }
+                
+                switch (c) {
+                case 'h':
+                case 'm':
+                case 's':
+                    if (accuracy != _DateUtil.ACCURACY_MILLISECONDS) {
+                        throw new InvalidFormatParametersException(
+                                "Character \"" + c + "\" is unexpected as 
accuracy was already specified earlier "
+                                + "(at char pos. " + i + ").");
+                    }
+                    switch (c) {
+                    case 'h':
+                        if (isXSMode()) {
+                            throw new InvalidFormatParametersException(
+                                    
XS_LESS_THAN_SECONDS_ACCURACY_ERROR_MESSAGE);
+                        }
+                        accuracy = _DateUtil.ACCURACY_HOURS;
+                        break;
+                    case 'm':
+                        if (i < ln && formatString.charAt(i) == 's') {
+                            i++;
+                            accuracy = _DateUtil.ACCURACY_MILLISECONDS_FORCED;
+                        } else {
+                            if (isXSMode()) {
+                                throw new InvalidFormatParametersException(
+                                        
XS_LESS_THAN_SECONDS_ACCURACY_ERROR_MESSAGE);
+                            }
+                            accuracy = _DateUtil.ACCURACY_MINUTES;
+                        }
+                        break;
+                    case 's':
+                        accuracy = _DateUtil.ACCURACY_SECONDS;
+                        break;
+                    }
+                    break;
+                case 'f':
+                    if (i < ln && formatString.charAt(i) == 'u') {
+                        checkForceUTCNotSet(forceUTC);
+                        i++;
+                        forceUTC = Boolean.TRUE;
+                        break;
+                    }
+                    // Falls through
+                case 'n':
+                    if (showZoneOffset != null) {
+                        throw new InvalidFormatParametersException(
+                                "Character \"" + c + "\" is unexpected as zone 
offset visibility was already "
+                                + "specified earlier. (at char pos. " + i + 
").");
+                    }
+                    switch (c) {
+                    case 'n':
+                        if (i < ln && formatString.charAt(i) == 'z') {
+                            i++;
+                            showZoneOffset = Boolean.FALSE;
+                        } else {
+                            throw new InvalidFormatParametersException(
+                                    "\"n\" must be followed by \"z\" (at char 
pos. " + i + ").");
+                        }
+                        break;
+                    case 'f':
+                        if (i < ln && formatString.charAt(i) == 'z') {
+                            i++;
+                            showZoneOffset = Boolean.TRUE;
+                        } else {
+                            throw new InvalidFormatParametersException(
+                                    "\"f\" must be followed by \"z\" (at char 
pos. " + i + ").");
+                        }
+                        break;
+                    }
+                    break;
+                case 'u':
+                    checkForceUTCNotSet(forceUTC);
+                    forceUTC = null;  // means UTC will be used except for 
zonelessInput
+                    break;
+                default:
+                    throw new InvalidFormatParametersException(
+                            "Unexpected character, " + 
_StringUtil.jQuote(String.valueOf(c))
+                            + ". Expected the beginning of one of: h, m, s, 
ms, nz, fz, u"
+                            + " (at char pos. " + i + ").");
+                } // switch
+                afterSeparator = false;
+            } // else
+        } // while
+        
+        this.accuracy = accuracy;
+        this.showZoneOffset = showZoneOffset;
+        this.forceUTC = forceUTC;
+        this.timeZone = timeZone;
+    }
+
+    private void checkForceUTCNotSet(Boolean fourceUTC) throws 
InvalidFormatParametersException {
+        if (fourceUTC != Boolean.FALSE) {
+            throw new InvalidFormatParametersException(
+                    "The UTC usage option was already set earlier.");
+        }
+    }
+    
+    @Override
+    public final String formatToPlainText(TemplateDateModel dateModel) throws 
TemplateModelException {
+        final Date date = TemplateFormatUtil.getNonNullDate(dateModel);
+        return format(
+                date,
+                dateType != TemplateDateModel.TIME,
+                dateType != TemplateDateModel.DATE,
+                showZoneOffset == null
+                        ? !zonelessInput
+                        : showZoneOffset.booleanValue(),
+                accuracy,
+                (forceUTC == null ? !zonelessInput : forceUTC.booleanValue()) 
? _DateUtil.UTC : timeZone,
+                factory.getISOBuiltInCalendar(env));
+    }
+    
+    protected abstract String format(Date date,
+            boolean datePart, boolean timePart, boolean offsetPart,
+            int accuracy,
+            TimeZone timeZone,
+            DateToISO8601CalendarFactory calendarFactory);
+
+    @Override
+    @SuppressFBWarnings(value = "RC_REF_COMPARISON_BAD_PRACTICE_BOOLEAN",
+            justification = "Known to use the singleton Boolean-s only")
+    public final Date parse(String s, int dateType) throws 
UnparsableValueException {
+        CalendarFieldsToDateConverter calToDateConverter = 
factory.getCalendarFieldsToDateCalculator(env);
+        TimeZone tz = forceUTC != Boolean.FALSE ? _DateUtil.UTC : timeZone;
+        try {
+            if (dateType == TemplateDateModel.DATE) {
+                return parseDate(s, tz, calToDateConverter);
+            } else if (dateType == TemplateDateModel.TIME) {
+                return parseTime(s, tz, calToDateConverter);
+            } else if (dateType == TemplateDateModel.DATETIME) {
+                return parseDateTime(s, tz, calToDateConverter);
+            } else {
+                throw new BugException("Unexpected date type: " + dateType);
+            }
+        } catch (DateParseException e) {
+            throw new UnparsableValueException(e.getMessage(), e);
+        }
+    }
+    
+    protected abstract Date parseDate(
+            String s, TimeZone tz,
+            CalendarFieldsToDateConverter calToDateConverter) 
+            throws DateParseException;
+    
+    protected abstract Date parseTime(
+            String s, TimeZone tz,
+            CalendarFieldsToDateConverter calToDateConverter) 
+            throws DateParseException;
+    
+    protected abstract Date parseDateTime(
+            String s, TimeZone tz,
+            CalendarFieldsToDateConverter calToDateConverter) 
+            throws DateParseException;
+
+    @Override
+    public final String getDescription() {
+        switch (dateType) {
+            case TemplateDateModel.DATE: return getDateDescription();
+            case TemplateDateModel.TIME: return getTimeDescription();
+            case TemplateDateModel.DATETIME: return getDateTimeDescription();
+            default: return "<error: wrong format dateType>";
+        }
+    }
+    
+    protected abstract String getDateDescription();
+    protected abstract String getTimeDescription();
+    protected abstract String getDateTimeDescription();
+    
+    @Override
+    public final boolean isLocaleBound() {
+        return false;
+    }
+    
+    @Override
+    public boolean isTimeZoneBound() {
+        return true;
+    }
+
+    protected abstract boolean isXSMode();
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormatFactory.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormatFactory.java
 
b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormatFactory.java
new file mode 100644
index 0000000..9a03a16
--- /dev/null
+++ 
b/src/main/java/org/apache/freemarker/core/ISOLikeTemplateDateFormatFactory.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+import org.apache.freemarker.core.util._DateUtil.CalendarFieldsToDateConverter;
+import org.apache.freemarker.core.util._DateUtil.DateToISO8601CalendarFactory;
+import 
org.apache.freemarker.core.util._DateUtil.TrivialCalendarFieldsToDateConverter;
+import 
org.apache.freemarker.core.util._DateUtil.TrivialDateToISO8601CalendarFactory;
+
+abstract class ISOLikeTemplateDateFormatFactory extends 
TemplateDateFormatFactory {
+    
+    private static final Object DATE_TO_CAL_CONVERTER_KEY = new Object();
+    private static final Object CAL_TO_DATE_CONVERTER_KEY = new Object();
+    
+    protected ISOLikeTemplateDateFormatFactory() { }
+
+    public DateToISO8601CalendarFactory getISOBuiltInCalendar(Environment env) 
{
+        DateToISO8601CalendarFactory r = (DateToISO8601CalendarFactory) 
env.getCustomState(DATE_TO_CAL_CONVERTER_KEY);
+        if (r == null) {
+            r = new TrivialDateToISO8601CalendarFactory();
+            env.setCustomState(DATE_TO_CAL_CONVERTER_KEY, r);
+        }
+        return r;
+    }
+
+    public CalendarFieldsToDateConverter 
getCalendarFieldsToDateCalculator(Environment env) {
+        CalendarFieldsToDateConverter r = (CalendarFieldsToDateConverter) 
env.getCustomState(CAL_TO_DATE_CONVERTER_KEY);
+        if (r == null) {
+            r = new TrivialCalendarFieldsToDateConverter();
+            env.setCustomState(CAL_TO_DATE_CONVERTER_KEY, r);
+        }
+        return r;
+    }
+    
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormat.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormat.java 
b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormat.java
new file mode 100644
index 0000000..f973628
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormat.java
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+import java.util.Date;
+import java.util.TimeZone;
+
+import org.apache.freemarker.core.util._DateUtil;
+import org.apache.freemarker.core.util._DateUtil.CalendarFieldsToDateConverter;
+import org.apache.freemarker.core.util._DateUtil.DateParseException;
+import org.apache.freemarker.core.util._DateUtil.DateToISO8601CalendarFactory;
+
+final class ISOTemplateDateFormat extends ISOLikeTemplateDateFormat {
+
+    ISOTemplateDateFormat(
+            String settingValue, int parsingStart,
+            int dateType, boolean zonelessInput,
+            TimeZone timeZone,
+            ISOLikeTemplateDateFormatFactory factory,
+            Environment env)
+            throws InvalidFormatParametersException, 
UnknownDateTypeFormattingUnsupportedException {
+        super(settingValue, parsingStart, dateType, zonelessInput, timeZone, 
factory, env);
+    }
+
+    @Override
+    protected String format(Date date, boolean datePart, boolean timePart, 
boolean offsetPart, int accuracy,
+            TimeZone timeZone, DateToISO8601CalendarFactory calendarFactory) {
+        return _DateUtil.dateToISO8601String(
+                date, datePart, timePart, timePart && offsetPart, accuracy, 
timeZone, calendarFactory);
+    }
+
+    @Override
+    protected Date parseDate(String s, TimeZone tz, 
CalendarFieldsToDateConverter calToDateConverter)
+            throws DateParseException {
+        return _DateUtil.parseISO8601Date(s, tz, calToDateConverter);
+    }
+
+    @Override
+    protected Date parseTime(String s, TimeZone tz, 
CalendarFieldsToDateConverter calToDateConverter)
+            throws DateParseException {
+        return _DateUtil.parseISO8601Time(s, tz, calToDateConverter);
+    }
+
+    @Override
+    protected Date parseDateTime(String s, TimeZone tz,
+            CalendarFieldsToDateConverter calToDateConverter) throws 
DateParseException {
+        return _DateUtil.parseISO8601DateTime(s, tz, calToDateConverter);
+    }
+    
+    @Override
+    protected String getDateDescription() {
+        return "ISO 8601 (subset) date";
+    }
+
+    @Override
+    protected String getTimeDescription() {
+        return "ISO 8601 (subset) time";
+    }
+
+    @Override
+    protected String getDateTimeDescription() {
+        return "ISO 8601 (subset) date-time";
+    }
+
+    @Override
+    protected boolean isXSMode() {
+        return false;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormatFactory.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormatFactory.java 
b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormatFactory.java
new file mode 100644
index 0000000..068e555
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/ISOTemplateDateFormatFactory.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+import java.util.Locale;
+import java.util.TimeZone;
+
+class ISOTemplateDateFormatFactory extends ISOLikeTemplateDateFormatFactory {
+    
+    static final ISOTemplateDateFormatFactory INSTANCE = new 
ISOTemplateDateFormatFactory();
+
+    private ISOTemplateDateFormatFactory() {
+        // Not meant to be instantiated
+    }
+
+    @Override
+    public TemplateDateFormat get(String params, int dateType, Locale locale, 
TimeZone timeZone, boolean zonelessInput,
+            Environment env) throws 
UnknownDateTypeFormattingUnsupportedException, InvalidFormatParametersException 
{
+        // We don't cache these as creating them is cheap (only 10% speedup of 
${d?string.xs} with caching)
+        return new ISOTemplateDateFormat(
+                params, 3,
+                dateType, zonelessInput,
+                timeZone, this, env);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/InvalidFormatParametersException.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/InvalidFormatParametersException.java
 
b/src/main/java/org/apache/freemarker/core/InvalidFormatParametersException.java
new file mode 100644
index 0000000..59e6458
--- /dev/null
+++ 
b/src/main/java/org/apache/freemarker/core/InvalidFormatParametersException.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+/**
+ * Used when creating {@link TemplateDateFormat}-s and {@link 
TemplateNumberFormat}-s to indicate that the parameters
+ * part of the format string (like some kind of pattern) is malformed.
+ * 
+ * @since 2.3.24
+ */
+public final class InvalidFormatParametersException extends 
InvalidFormatStringException {
+    
+    public InvalidFormatParametersException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public InvalidFormatParametersException(String message) {
+        this(message, null);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/InvalidFormatStringException.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/InvalidFormatStringException.java 
b/src/main/java/org/apache/freemarker/core/InvalidFormatStringException.java
new file mode 100644
index 0000000..e7cb59b
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/InvalidFormatStringException.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+/**
+ * Used when creating {@link TemplateDateFormat}-s and {@link 
TemplateNumberFormat}-s to indicate that the format
+ * string (like the value of the {@code dateFormat} setting) is malformed.
+ * 
+ * @since 2.3.24
+ */
+public abstract class InvalidFormatStringException extends 
TemplateValueFormatException {
+    
+    public InvalidFormatStringException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public InvalidFormatStringException(String message) {
+        this(message, null);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/InvalidReferenceException.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/InvalidReferenceException.java 
b/src/main/java/org/apache/freemarker/core/InvalidReferenceException.java
new file mode 100644
index 0000000..924686b
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/InvalidReferenceException.java
@@ -0,0 +1,167 @@
+/*
+ * 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.
+ */
+
+package org.apache.freemarker.core;
+
+/**
+ * A subclass of {@link TemplateException} that says that an FTL expression 
has evaluated to {@code null} or it refers
+ * to something that doesn't exist. At least in FreeMarker 2.3.x these two 
cases aren't distinguished.
+ */
+public class InvalidReferenceException extends TemplateException {
+
+    static final InvalidReferenceException FAST_INSTANCE;
+    static {
+        Environment prevEnv = Environment.getCurrentEnvironment();
+        try {
+            Environment.setCurrentEnvironment(null);
+            FAST_INSTANCE = new InvalidReferenceException(
+                    "Invalid reference. Details are unavilable, as this should 
have been handled by an FTL construct. "
+                    + "If it wasn't, that's problably a bug in FreeMarker.",
+                    null);
+        } finally {
+            Environment.setCurrentEnvironment(prevEnv);
+        }
+    }
+    
+    private static final Object[] TIP = {
+        "If the failing expression is known to be legally refer to something 
that's sometimes null or missing, "
+        + "either specify a default value like myOptionalVar!myDefault, or use 
",
+        "<#if myOptionalVar??>", "when-present", "<#else>", "when-missing", 
"</#if>",
+        ". (These only cover the last step of the expression; to cover the 
whole expression, "
+        + "use parenthesis: (myOptionalVar.foo)!myDefault, 
(myOptionalVar.foo)??"
+    };
+
+    private static final Object[] TIP_MISSING_ASSIGNMENT_TARGET = {
+            "If the target variable is known to be legally null or missing 
sometimes, instead of something like ",
+            "<#assign x += 1>", ", you could write ", "<#if x??>", "<#assign x 
+= 1>", "</#if>",
+            " or ", "<#assign x = (x!0) + 1>"
+    };
+    
+    private static final String TIP_NO_DOLLAR =
+            "Variable references must not start with \"$\", unless the \"$\" 
is really part of the variable name.";
+
+    private static final String TIP_LAST_STEP_DOT =
+            "It's the step after the last dot that caused this error, not 
those before it.";
+
+    private static final String TIP_LAST_STEP_SQUARE_BRACKET =
+            "It's the final [] step that caused this error, not those before 
it.";
+    
+    private static final String TIP_JSP_TAGLIBS =
+            "The \"JspTaglibs\" variable isn't a core FreeMarker feature; "
+            + "it's only available when templates are invoked through 
org.apache.freemarker.servlet.FreemarkerServlet"
+            + " (or other custom FreeMarker-JSP integration solution).";
+    
+    /**
+     * Creates and invalid reference exception that contains no information 
about what was missing or null.
+     * As such, try to avoid this constructor.
+     */
+    public InvalidReferenceException(Environment env) {
+        super("Invalid reference: The expression has evaluated to null or 
refers to something that doesn't exist.",
+                env);
+    }
+
+    /**
+     * Creates and invalid reference exception that contains no 
programmatically extractable information about the
+     * blamed expression. As such, try to avoid this constructor, unless need 
to raise this expression from outside
+     * the FreeMarker core.
+     */
+    public InvalidReferenceException(String description, Environment env) {
+        super(description, env);
+    }
+
+    /**
+     * This is the recommended constructor, but it's only used internally, and 
has no backward compatibility guarantees.
+     * 
+     * @param expression The expression that evaluates to missing or null. The 
last step of the expression should be
+     *     the failing one, like in {@code goodStep.failingStep.furtherStep} 
it should only contain
+     *     {@code goodStep.failingStep}.
+     */
+    InvalidReferenceException(_ErrorDescriptionBuilder description, 
Environment env, ASTExpression expression) {
+        super(null, env, expression, description);
+    }
+
+    /**
+     * Use this whenever possible, as it returns {@link #FAST_INSTANCE} 
instead of creating a new instance, when
+     * appropriate.
+     */
+    static InvalidReferenceException getInstance(ASTExpression blamed, 
Environment env) {
+        if (env != null && env.getFastInvalidReferenceExceptions()) {
+            return FAST_INSTANCE;
+        } else {
+            if (blamed != null) {
+                final _ErrorDescriptionBuilder errDescBuilder
+                        = new _ErrorDescriptionBuilder("The following has 
evaluated to null or missing:").blame(blamed);
+                if (endsWithDollarVariable(blamed)) {
+                    errDescBuilder.tips(TIP_NO_DOLLAR, TIP);
+                } else if (blamed instanceof ASTExpDot) {
+                    final String rho = ((ASTExpDot) blamed).getRHO();
+                    String nameFixTip = null;
+                    if ("size".equals(rho)) {
+                        nameFixTip = "To query the size of a collection or map 
use ?size, like myList?size";
+                    } else if ("length".equals(rho)) {
+                        nameFixTip = "To query the length of a string use 
?length, like myString?size";
+                    }
+                    errDescBuilder.tips(
+                            nameFixTip == null
+                                    ? new Object[] { TIP_LAST_STEP_DOT, TIP }
+                                    : new Object[] { TIP_LAST_STEP_DOT, 
nameFixTip, TIP });
+                } else if (blamed instanceof ASTExpDynamicKeyName) {
+                    errDescBuilder.tips(TIP_LAST_STEP_SQUARE_BRACKET, TIP);
+                } else if (blamed instanceof ASTExpVariable
+                        && ((ASTExpVariable) 
blamed).getName().equals("JspTaglibs")) {
+                    errDescBuilder.tips(TIP_JSP_TAGLIBS, TIP);
+                } else {
+                    errDescBuilder.tip(TIP);
+                }
+                return new InvalidReferenceException(errDescBuilder, env, 
blamed);
+            } else {
+                return new InvalidReferenceException(env);
+            }
+        }
+    }
+    
+    /**
+     * Used for assignments that use operators like {@code +=}, when the 
target variable was null/missing. 
+     */
+    static InvalidReferenceException getInstance(String 
missingAssignedVarName, String assignmentOperator,
+            Environment env) {
+        if (env != null && env.getFastInvalidReferenceExceptions()) {
+            return FAST_INSTANCE;
+        } else {
+            final _ErrorDescriptionBuilder errDescBuilder = new 
_ErrorDescriptionBuilder(
+                            "The target variable of the assignment, ",
+                            new _DelayedJQuote(missingAssignedVarName),
+                            ", was null or missing, but the \"",
+                            assignmentOperator, "\" operator needs to get its 
value before assigning to it."
+                    );
+            if (missingAssignedVarName.startsWith("$")) {
+                errDescBuilder.tips(TIP_NO_DOLLAR, 
TIP_MISSING_ASSIGNMENT_TARGET);
+            } else {
+                errDescBuilder.tip(TIP_MISSING_ASSIGNMENT_TARGET);
+            }
+            return new InvalidReferenceException(errDescBuilder, env, null);
+        }
+    }
+
+    private static boolean endsWithDollarVariable(ASTExpression blame) {
+        return blame instanceof ASTExpVariable && ((ASTExpVariable) 
blame).getName().startsWith("$")
+                || blame instanceof ASTExpDot && ((ASTExpDot) 
blame).getRHO().startsWith("$");
+    }
+    
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/JSONOutputFormat.java
----------------------------------------------------------------------
diff --git a/src/main/java/org/apache/freemarker/core/JSONOutputFormat.java 
b/src/main/java/org/apache/freemarker/core/JSONOutputFormat.java
new file mode 100644
index 0000000..8e76701
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/JSONOutputFormat.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+/**
+ * Represents the JSON output format (MIME type "application/json", name 
"JSON"). This format doesn't support escaping.
+ * 
+ * @since 2.3.24
+ */
+public class JSONOutputFormat extends OutputFormat {
+
+    /**
+     * The only instance (singleton) of this {@link OutputFormat}.
+     */
+    public static final JSONOutputFormat INSTANCE = new JSONOutputFormat();
+    
+    private JSONOutputFormat() {
+        // Only to decrease visibility
+    }
+    
+    @Override
+    public String getName() {
+        return "JSON";
+    }
+
+    @Override
+    public String getMimeType() {
+        return "application/json";
+    }
+
+    @Override
+    public boolean isOutputFormatMixingAllowed() {
+        return false;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/7d784b2b/src/main/java/org/apache/freemarker/core/JavaScriptOutputFormat.java
----------------------------------------------------------------------
diff --git 
a/src/main/java/org/apache/freemarker/core/JavaScriptOutputFormat.java 
b/src/main/java/org/apache/freemarker/core/JavaScriptOutputFormat.java
new file mode 100644
index 0000000..1b35f99
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/core/JavaScriptOutputFormat.java
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+package org.apache.freemarker.core;
+
+/**
+ * Represents the JavaScript output format (MIME type 
"application/javascript", name "JavaScript"). This format doesn't
+ * support escaping.
+ * 
+ * @since 2.3.24
+ */
+public class JavaScriptOutputFormat extends OutputFormat {
+
+    /**
+     * The only instance (singleton) of this {@link OutputFormat}.
+     */
+    public static final JavaScriptOutputFormat INSTANCE = new 
JavaScriptOutputFormat();
+    
+    private JavaScriptOutputFormat() {
+        // Only to decrease visibility
+    }
+    
+    @Override
+    public String getName() {
+        return "JavaScript";
+    }
+
+    @Override
+    public String getMimeType() {
+        return "application/javascript";
+    }
+
+    @Override
+    public boolean isOutputFormatMixingAllowed() {
+        return false;
+    }
+
+}


Reply via email to