This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit 7f6e528006c34a82f1b79481209cb8ea2439d101
Author: ddekany <[email protected]>
AuthorDate: Sun Jun 7 21:23:18 2020 +0200

    FREEMARKER-35: Reworked things so that it will allow format caching and 
custom formatters later. Basically TemporalUtils was sliced up to 
TemplateTemporalFormat-s and their factories, and to some Environment code. 
Fixed many rough edges along the way, and discovered even more (added TODO 
comments for them).
---
 ...seJavaTemplateTemporalFormatTemplateFormat.java |  64 ++++++
 .../freemarker/core/BuiltInsForMultipleTypes.java  |  11 +-
 src/main/java/freemarker/core/Configurable.java    |  16 +-
 src/main/java/freemarker/core/Environment.java     | 254 ++++++++++++++++-----
 src/main/java/freemarker/core/EvalUtil.java        |  12 +-
 .../core/ISOLikeTemplateTemporalFormat.java        |  47 ++++
 .../core/ISOTemplateTemporalFormatFactory.java     | 118 ++++++++++
 .../core/JavaTemplateTemporalFormat.java           | 129 +++++++++++
 .../core/JavaTemplateTemporalFormatFactory.java    |  42 ++++
 .../freemarker/core/TemplateTemporalFormat.java    |  44 ++--
 .../core/TemplateTemporalFormatFactory.java        |  81 +++++++
 ...at.java => ToStringTemplateTemporalFormat.java} |  46 ++--
 .../ToStringTemplateTemporalFormatFactory.java     |  42 ++++
 .../core/UnformattableTemporalTypeException.java   |  38 +++
 .../core/XSTemplateTemporalFormatFactory.java      | 124 ++++++++++
 .../java/freemarker/core/_CoreTemporalUtils.java   |  40 +++-
 src/main/java/freemarker/core/_MessageUtil.java    |   4 +-
 .../freemarker/template/utility/TemporalUtil.java  | 197 ----------------
 ...igurableTest.java => CoreTemporalUtilTest.java} |  26 ++-
 .../freemarker/core/TemporalErrorMessagesTest.java |  55 +++++
 .../test/templatesuite/TemplateTestCase.java       |   2 +
 .../test/templatesuite/templates/temporal.ftl      |   4 +
 22 files changed, 1070 insertions(+), 326 deletions(-)

diff --git 
a/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java
 
b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java
new file mode 100644
index 0000000..a7e7412
--- /dev/null
+++ 
b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java
@@ -0,0 +1,64 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.Temporal;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+abstract class BaseJavaTemplateTemporalFormatTemplateFormat extends 
TemplateTemporalFormat {
+    private final DateTimeFormatter dateTimeFormatterWithZoneOverride;
+
+    protected BaseJavaTemplateTemporalFormatTemplateFormat(DateTimeFormatter 
dateTimeFormatterWithZoneOverride) {
+        this.dateTimeFormatterWithZoneOverride = 
dateTimeFormatterWithZoneOverride;
+    }
+
+    @Override
+    public String format(TemplateTemporalModel tm)
+            throws TemplateValueFormatException, TemplateModelException {
+        try {
+            DateTimeFormatter dateTimeFormatter = 
this.dateTimeFormatterWithZoneOverride;
+            Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
+
+            // TODO [FREEMARKER-35] Doing these on runtime is wasteful if it's 
know if for which format setting
+            // this object is used for.
+            if (temporal instanceof Instant) {
+                temporal = ((Instant) 
temporal).atZone(dateTimeFormatter.getZone());
+            } else if (temporal instanceof OffsetDateTime) {
+                dateTimeFormatter = 
dateTimeFormatter.withZone(((OffsetDateTime) temporal).getOffset());
+            } else if (temporal instanceof OffsetTime) {
+                dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) 
temporal).getOffset());
+            } else if (temporal instanceof ZonedDateTime) {
+                dateTimeFormatter = dateTimeFormatter.withZone(null);
+            }
+
+            return dateTimeFormatter.format(temporal);
+        } catch (DateTimeException e) {
+            throw new UnformattableValueException(e.getMessage(), e);
+        }
+    }
+}
diff --git a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java 
b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
index 1159d5d..8cc6942 100644
--- a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
+++ b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java
@@ -606,7 +606,6 @@ class BuiltInsForMultipleTypes {
             }
         }
     
-
         private class TemporalFormatter implements TemplateScalarModel, 
TemplateHashModel, TemplateMethodModel {
             private final TemplateTemporalModel temporalModel;
             private final Environment env;
@@ -616,7 +615,7 @@ class BuiltInsForMultipleTypes {
             TemporalFormatter(TemplateTemporalModel temporalModel, Environment 
env) throws TemplateException {
                 this.temporalModel = temporalModel;
                 this.env = env;
-                this.defaultFormat = 
env.getTemplateTemporalFormat(temporalModel.getAsTemporal().getClass());
+                this.defaultFormat = 
env.getTemplateTemporalFormat(temporalModel, target, false);
             }
 
             @Override
@@ -633,12 +632,10 @@ class BuiltInsForMultipleTypes {
             private TemplateModel formatWith(String key)
                     throws TemplateModelException {
                 try {
-                    return new 
SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target));
+                    return new 
SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target, 
stringBI.this, true));
                 } catch (TemplateException e) {
                     // `e` should always be a TemplateModelException here, but 
to be sure:
                     throw _CoreAPI.ensureIsTemplateModelException("Failed to 
format value", e);
-                } catch (TemplateValueFormatException e) {
-                    throw new _TemplateModelException("Failed to format 
value", e);
                 }
             }
 
@@ -652,7 +649,7 @@ class BuiltInsForMultipleTypes {
                         cachedValue = 
EvalUtil.assertFormatResultNotNull(defaultFormat.format(temporalModel));
                     } catch (TemplateValueFormatException e) {
                         try {
-                            throw 
_MessageUtil.newCantFormatDateException(defaultFormat, target, e, true);
+                            throw 
_MessageUtil.newCantFormatTemporalException(defaultFormat, target, e, true);
                         } catch (TemplateException e2) {
                             // `e` should always be a TemplateModelException 
here, but to be sure:
                             throw 
_CoreAPI.ensureIsTemplateModelException("Failed to format date/time/datetime", 
e2);
@@ -688,7 +685,7 @@ class BuiltInsForMultipleTypes {
                 this.defaultFormat = dateType == TemplateDateModel.UNKNOWN
                         ? null  // Lazy unknown type error in getAsString()
                         : env.getTemplateDateFormat(
-                                dateType, EvalUtil.modelToDate(dateModel, 
target).getClass(), target, true);
+                                dateType, EvalUtil.modelToDate(dateModel, 
target).getClass(), target, false);
             }
     
             @Override
diff --git a/src/main/java/freemarker/core/Configurable.java 
b/src/main/java/freemarker/core/Configurable.java
index c891510..d3c8646 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -46,6 +46,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Properties;
 import java.util.Set;
 import java.util.TimeZone;
@@ -1414,11 +1415,12 @@ public class Configurable {
      * @return Never {@code null}, maybe {@code ""} though.
      *
      * @throws NullPointerException If {@link temporalClass} was {@code null}
-     * @throws IllegalArgumentException If {@link temporalClass} was not a 
supported {@link Temporal} subclass.
+     * @throws IllegalArgumentException If {@link temporalClass} is not a 
supported {@link Temporal} subclass.
      *
      * @since 2.3.31
      */
     public String getTemporalFormat(Class<? extends Temporal> temporalClass) {
+        Objects.requireNonNull(temporalClass);
         if (temporalClass == Instant.class) {
             return getInstantFormat();
         } else if (temporalClass == LocalDate.class) {
@@ -1524,7 +1526,17 @@ public class Configurable {
         }
         return parent != null ? parent.getCustomDateFormat(name) : null;
     }
-    
+
+    /**
+     * Gets the custom name format registered for the name.
+     *
+     * @since 2.3.31
+     */
+    public TemplateTemporalFormatFactory getCustomTemporalFormat(String name) {
+        // TODO [FREEMARKER-35]
+        return null;
+    }
+
     /**
      * Sets the exception handler used to handle exceptions occurring inside 
templates.
      * The default is {@link TemplateExceptionHandler#DEBUG_HANDLER}. The 
recommended values are:
diff --git a/src/main/java/freemarker/core/Environment.java 
b/src/main/java/freemarker/core/Environment.java
index 3a0a251..0e57db4 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -1637,13 +1637,7 @@ public final class Environment extends Configurable {
             final String name;
             final String params;
             {
-                int endIdx;
-                findParamsStart: for (endIdx = 1; endIdx < formatStringLen; 
endIdx++) {
-                    char c = formatString.charAt(endIdx);
-                    if (c == ' ' || c == '_') {
-                        break findParamsStart;
-                    }
-                }
+                int endIdx = getCustomFormatStringNameEnd(formatString, 
formatStringLen);
                 name = formatString.substring(1, endIdx);
                 params = endIdx < formatStringLen ? 
formatString.substring(endIdx + 1) : "";
             }
@@ -1729,62 +1723,37 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * @param tdmSourceExpr
+     * @param blamedTdmSourceExpr
      *            The blamed expression if an error occurs; only used for 
error messages.
      */
-    String formatDateToPlainText(TemplateDateModel tdm, Expression 
tdmSourceExpr,
+    String formatDateToPlainText(TemplateDateModel tdm, Expression 
blamedTdmSourceExpr,
             boolean useTempModelExc) throws TemplateException {
-        TemplateDateFormat format = getTemplateDateFormat(tdm, tdmSourceExpr, 
useTempModelExc);
-        
+        TemplateDateFormat format = getTemplateDateFormat(tdm, 
blamedTdmSourceExpr, useTempModelExc);
         try {
             return 
EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm));
         } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(format, 
tdmSourceExpr, e, useTempModelExc);
+            throw _MessageUtil.newCantFormatDateException(format, 
blamedTdmSourceExpr, e, useTempModelExc);
         }
     }
 
     /**
-     * @param blamedDateSourceExp
+     * @param blamedTdmSourceExp
      *            The blamed expression if an error occurs; only used for 
error messages.
      * @param blamedFormatterExp
      *            The blamed expression if an error occurs; only used for 
error messages.
      */
     String formatDateToPlainText(TemplateDateModel tdm, String formatString,
-            Expression blamedDateSourceExp, Expression blamedFormatterExp,
+            Expression blamedTdmSourceExp, Expression blamedFormatterExp,
             boolean useTempModelExc) throws TemplateException {
-        Date date = EvalUtil.modelToDate(tdm, blamedDateSourceExp);
-        
         TemplateDateFormat format = getTemplateDateFormat(
-                formatString, tdm.getDateType(), date.getClass(),
-                blamedDateSourceExp, blamedFormatterExp,
+                formatString, tdm.getDateType(), EvalUtil.modelToDate(tdm, 
blamedTdmSourceExp).getClass(),
+                blamedTdmSourceExp, blamedFormatterExp,
                 useTempModelExc);
         
         try {
             return 
EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm));
         } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(format, 
blamedDateSourceExp, e, useTempModelExc);
-        }
-    }
-
-    /**
-     * @param blamedDateSourceExp
-     *            The blamed expression if an error occurs; only used for 
error messages.
-     */
-    String formatTemporalToPlainText(TemplateTemporalModel ttm, String 
formatString, Expression blamedDateSourceExp) throws TemplateException, 
TemplateValueFormatException {
-        TemplateTemporalFormat ttf = 
getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), formatString, true);
-        try {
-            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
-        } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(ttf, 
blamedDateSourceExp, e, true);
-        }
-    }
-
-    String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression 
tdmSourceExpr) throws TemplateException {
-        TemplateTemporalFormat ttf = 
getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
-        try {
-            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
-        } catch (TemplateValueFormatException e) {
-            throw _MessageUtil.newCantFormatDateException(ttf, tdmSourceExpr, 
e, false);
+            throw _MessageUtil.newCantFormatDateException(format, 
blamedTdmSourceExp, e, useTempModelExc);
         }
     }
 
@@ -1963,7 +1932,8 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the 
exceptions to {@link TemplateException}-s.
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the 
exceptions to moer informative
+     * {@link TemplateException}-s.
      */
     TemplateDateFormat getTemplateDateFormat(
             int dateType, Class<? extends Date> dateClass, Expression 
blamedDateSourceExp, boolean useTempModelExc)
@@ -2003,7 +1973,7 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(String, int, Class)}, but 
translates the exceptions to
+     * Same as {@link #getTemplateDateFormat(String, int, Class)}, but 
translates the exceptions to more informative
      * {@link TemplateException}-s.
      */
     TemplateDateFormat getTemplateDateFormat(
@@ -2164,13 +2134,7 @@ public final class Environment extends Configurable {
                 && Character.isLetter(formatString.charAt(1))) {
             final String name;
             {
-                int endIdx;
-                findParamsStart: for (endIdx = 1; endIdx < formatStringLen; 
endIdx++) {
-                    char c = formatString.charAt(endIdx);
-                    if (c == ' ' || c == '_') {
-                        break findParamsStart;
-                    }
-                }
+                int endIdx = getCustomFormatStringNameEnd(formatString, 
formatStringLen);
                 name = formatString.substring(1, endIdx);
                 formatParams = endIdx < formatStringLen ? 
formatString.substring(endIdx + 1) : "";
             }
@@ -2219,14 +2183,192 @@ public final class Environment extends Configurable {
                 + (sqlDTTZ ? CACHED_TDFS_SQL_D_T_TZ_OFFS : 0);
     }
 
-    TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> 
temporalClass) {
-        // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by 
temporalFormat (instantFormat, localDateFormat, etc.), locale and timeZone 
change.
-        return getTemplateTemporalFormat(temporalClass, 
getTemporalFormat(temporalClass), true);
+    /**
+     * @param blamedTtmSourceExp
+     *            The blamed expression if an error occurs; only used for 
error messages.
+     */
+    String formatTemporalToPlainText(TemplateTemporalModel ttm, String 
formatString,
+            Expression blamedTtmSourceExp, Expression blamedFormatterSourceExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        TemplateTemporalFormat ttf = getTemplateTemporalFormat(
+                formatString, ttm,
+                blamedTtmSourceExp, blamedFormatterSourceExp,
+                useTempModelExc);
+        try {
+            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
+        } catch (TemplateValueFormatException e) {
+            throw _MessageUtil.newCantFormatTemporalException(ttf, 
blamedTtmSourceExp, e, true);
+        }
+    }
+
+    String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression 
blamedTtmSourceExp,
+            boolean useTempModelExc) throws TemplateException {
+        TemplateTemporalFormat ttf = getTemplateTemporalFormat(
+                ttm, blamedTtmSourceExp, useTempModelExc);
+        try {
+            return EvalUtil.assertFormatResultNotNull(ttf.format(ttm));
+        } catch (TemplateValueFormatException e) {
+            throw _MessageUtil.newCantFormatTemporalException(ttf, 
blamedTtmSourceExp, e, false);
+        }
+    }
+
+    /**
+     * Convenience overload of {@link #getTemplateTemporalFormat(Class, 
Expression, boolean)}.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            TemplateTemporalModel ttm, Expression blamedTemporalSourceExp, 
boolean useTempModelExc)
+            throws TemplateException {
+        return getTemplateTemporalFormat(
+                EvalUtil.modelToTemporal(
+                        ttm, blamedTemporalSourceExp).getClass(), 
blamedTemporalSourceExp, useTempModelExc);
+    }
+
+    /**
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the 
exceptions to moer informative
+     * {@link TemplateException}-s.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            Class<? extends Temporal> temporalClass, Expression 
blamedTemporalSourceExp, boolean useTempModelExc)
+            throws TemplateException {
+        try {
+            return getTemplateTemporalFormat(temporalClass);
+        } catch (TemplateValueFormatException e) {
+            String settingName;
+            String settingValue;
+            try {
+                settingName = 
_CoreTemporalUtils.temporalClassToFormatSettingName(temporalClass);
+                settingValue = getTemporalFormat(temporalClass);
+            } catch (IllegalArgumentException e2) {
+                settingName = "???";
+                settingValue = "???";
+            }
+
+            _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
+                    "The value of the \"", settingName,
+                    "\" FreeMarker configuration setting is a malformed 
temporal format string: ",
+                    new _DelayedJQuote(settingValue), ". Reason given: ",
+                    e.getMessage());
+            throw useTempModelExc ? new _TemplateModelException(e, desc) : new 
_MiscTemplateException(e, desc);
+        }
     }
 
-    private TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends 
Temporal> temporalClass, String format, boolean cache) {
-        // TODO [FREEMARKER-35] format keyed cache, invalidated by local and 
timeZone change.
-        return new TemplateTemporalFormat(format, getLocale(), getTimeZone());
+    TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> 
temporalClass)
+            throws TemplateValueFormatException {
+        // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by 
temporalFormat (instantFormat, localDateFormat, etc.), locale, and timeZone 
change.
+        return getTemplateTemporalFormat(getTemporalFormat(temporalClass), 
temporalClass);
+    }
+
+    /**
+     * Convenience overload of {@link #getTemplateTemporalFormat(String, 
Class, Expression, Expression, boolean)}.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            String formatString, TemplateTemporalModel ttm,
+            Expression blamedTemporalSourceExp, Expression blamedFormatterExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        return getTemplateTemporalFormat(
+                formatString, EvalUtil.modelToTemporal(ttm, 
blamedFormatterExp).getClass(),
+                blamedTemporalSourceExp, blamedFormatterExp,
+                useTempModelExc);
+    }
+
+    /**
+     * Same as {@link #getTemplateTemporalFormat(String, Class)}, but 
translates the exceptions to more informative
+     * {@link TemplateException}-s.
+     */
+    TemplateTemporalFormat getTemplateTemporalFormat(
+            String formatString, Class<? extends Temporal> temporalClass,
+            Expression blamedTemporalSourceExp, Expression blamedFormatterExp,
+            boolean useTempModelExc)
+            throws TemplateException {
+        try {
+            return getTemplateTemporalFormat(formatString, temporalClass);
+        } catch (TemplateValueFormatException e) {
+            _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
+                    "Can't create temporal format based on format string ",
+                    new _DelayedJQuote(formatString), ". Reason given: ",
+                    e.getMessage())
+                    .blame(blamedFormatterExp);
+            throw useTempModelExc ? new _TemplateModelException(e, desc) : new 
_MiscTemplateException(e, desc);
+        }
+    }
+
+    private TemplateTemporalFormat getTemplateTemporalFormat(String 
formatString, Class<? extends Temporal> temporalClass)
+            throws TemplateValueFormatException {
+        // TODO [FREEMARKER-35] format keyed cache, invalidated by locale, and 
timeZone change.
+        return getTemplateTemporalFormatWithoutCache(formatString, 
temporalClass, getLocale(), getTimeZone());
+    }
+
+    /**
+     * Returns the {@link TemplateTemporalFormat} for the given parameters 
without using the {@link Environment}-level
+     * cache. Of course, the {@link TemplateTemporalFormatFactory} involved 
might still uses its own cache, which can be
+     * global (class-loader-level) or {@link Environment}-level.
+     *
+     * @param formatString
+     *            See the similar parameter of {@link 
TemplateTemporalFormatFactory#get}
+     * @param dateType
+     *            See the similar parameter of {@link 
TemplateTemporalFormatFactory#get}
+     * @param zonelessInput
+     *            See the similar parameter of {@link 
TemplateTemporalFormatFactory#get}
+     */
+    private TemplateTemporalFormat getTemplateTemporalFormatWithoutCache(
+            String formatString, Class<? extends Temporal> temporalClass, 
Locale locale, TimeZone timeZone)
+            throws TemplateValueFormatException {
+        final int formatStringLen = formatString.length();
+        final String formatParams;
+
+        TemplateTemporalFormatFactory formatFactory;
+        char firstChar = formatStringLen != 0 ? formatString.charAt(0) : 0;
+
+        if (firstChar == 'x'
+                && formatStringLen > 1
+                && formatString.charAt(1) == 's') {
+            formatFactory = XSTemplateTemporalFormatFactory.INSTANCE;
+            formatParams = formatString.substring(2);
+        } else if (firstChar == 'i'
+                && formatStringLen > 2
+                && formatString.charAt(1) == 's'
+                && formatString.charAt(2) == 'o') {
+            formatFactory = ISOTemplateTemporalFormatFactory.INSTANCE;
+            formatParams = formatString.substring(3);
+        } else if (firstChar == '@'
+                && formatStringLen > 1
+                && Character.isLetter(formatString.charAt(1))) {
+            final String name;
+            {
+                int endIdx = getCustomFormatStringNameEnd(formatString, 
formatStringLen);
+                name = formatString.substring(1, endIdx);
+                formatParams = endIdx < formatStringLen ? 
formatString.substring(endIdx + 1) : "";
+            }
+
+            formatFactory = getCustomTemporalFormat(name);
+            if (formatFactory == null) {
+                throw new UndefinedCustomFormatException(
+                        "No custom temporal format was defined with name " + 
StringUtil.jQuote(name));
+            }
+        } else if (formatStringLen == 0) {
+            // TODO [FREEMARKER-35] This is not right, but for now we mimic 
what TemporalUtils did
+            formatParams = formatString;
+            formatFactory = ToStringTemplateTemporalFormatFactory.INSTANCE;
+        } else {
+            formatParams = formatString;
+            formatFactory = JavaTemplateTemporalFormatFactory.INSTANCE;
+        }
+
+        return formatFactory.get(formatParams, temporalClass, locale, 
timeZone, this);
+    }
+
+    private static int getCustomFormatStringNameEnd(String formatString, int 
formatStringLen) {
+        int endIdx;
+        findParamsStart:
+        for (endIdx = 1; endIdx < formatStringLen; endIdx++) {
+            char c = formatString.charAt(endIdx);
+            if (c == ' ' || c == '_') {
+                break findParamsStart;
+            }
+        }
+        return endIdx;
     }
 
     /**
diff --git a/src/main/java/freemarker/core/EvalUtil.java 
b/src/main/java/freemarker/core/EvalUtil.java
index 50f32c5..78d626a 100644
--- a/src/main/java/freemarker/core/EvalUtil.java
+++ b/src/main/java/freemarker/core/EvalUtil.java
@@ -409,11 +409,11 @@ class EvalUtil {
             }
         } else if (tm instanceof TemplateTemporalModel) {
             TemplateTemporalModel ttm = (TemplateTemporalModel) tm;
-            TemplateTemporalFormat format = 
env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
+            TemplateTemporalFormat format = 
env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), exp, false);
             try {
                 return assertFormatResultNotNull(format.format(ttm));
             } catch (TemplateValueFormatException e) {
-                throw _MessageUtil.newCantFormatDateException(format, exp, e, 
false);
+                throw _MessageUtil.newCantFormatTemporalException(format, exp, 
e, false);
             }
         } else if (tm instanceof TemplateMarkupOutputModel) {
             return tm;
@@ -424,7 +424,7 @@ class EvalUtil {
 
     /**
      * Like {@link #coerceModelToStringOrMarkup(TemplateModel, Expression, 
String, Environment)}, but gives error
-     * if the result is markup. This is what you normally use where markup 
results can't be used.
+     * if the result is markup. This is what you normally used 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.
@@ -452,11 +452,11 @@ class EvalUtil {
             }
         } else if (tm instanceof TemplateTemporalModel) {
             TemplateTemporalModel ttm = (TemplateTemporalModel) tm;
-            TemplateTemporalFormat format = 
env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass());
+            TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm, 
exp, false);
             try {
                 return ensureFormatResultString(format.format(ttm), exp, env);
             } catch (TemplateValueFormatException e) {
-                throw _MessageUtil.newCantFormatDateException(format, exp, e, 
false);
+                throw _MessageUtil.newCantFormatTemporalException(format, exp, 
e, false);
             }
         } else {
             return coerceModelToTextualCommon(tm, exp, seqTip, false, false, 
env);
@@ -480,7 +480,7 @@ class EvalUtil {
         } else if (tm instanceof TemplateDateModel) {
             return 
assertFormatResultNotNull(env.formatDateToPlainText((TemplateDateModel) tm, 
exp, false));
         } else if (tm instanceof TemplateTemporalModel) {
-            return 
assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) 
tm, exp));
+            return 
assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) 
tm, exp, false));
         } else {
             return coerceModelToTextualCommon(tm, exp, seqTip, false, false, 
env);
         }
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java
new file mode 100644
index 0000000..8a8f8ff
--- /dev/null
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java
@@ -0,0 +1,47 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.format.DateTimeFormatter;
+
+// TODO [FREEMARKER-35] These should support parameters similar to {@link 
ISOTemplateDateFormat},
+final class ISOLikeTemplateTemporalFormat extends 
BaseJavaTemplateTemporalFormatTemplateFormat {
+    private final String description;
+
+    public ISOLikeTemplateTemporalFormat(DateTimeFormatter dateTimeFormatter, 
String description) {
+        super(dateTimeFormatter);
+        this.description = description;
+    }
+
+    @Override
+    public boolean isLocaleBound() {
+        return false;
+    }
+
+    @Override
+    public boolean isTimeZoneBound() {
+        return true;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+}
diff --git 
a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..58e47c0
--- /dev/null
+++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
@@ -0,0 +1,118 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.LocalTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    static final ISOTemplateTemporalFormatFactory INSTANCE = new 
ISOTemplateTemporalFormatFactory();
+
+    private static final DateTimeFormatter ISO8601_DATE_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .optionalStart()
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter ISO8601_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendLiteral("-")
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    static final DateTimeFormatter ISO8601_YEAR_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
+            TemplateValueFormatException {
+        if (!params.isEmpty()) {
+            // TODO [FREEMARKER-35]
+            throw new InvalidFormatParametersException("xs currently doesn't 
support parameters");
+        }
+
+        return getXSFormatter(temporalClass, timeZone.toZoneId());
+    }
+
+    private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? 
extends Temporal> temporalClass, ZoneId timeZone) {
+        final DateTimeFormatter dateTimeFormatter;
+        final String description;
+        if (temporalClass == LocalTime.class) {
+            dateTimeFormatter = ISO8601_TIME_FORMAT;
+            description = "ISO 8601 (subset) time";
+        } else if (temporalClass == Year.class) {
+            dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO
+            description = "ISO 8601 (subset) year";
+        } else if (temporalClass == YearMonth.class) {
+            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            description = "ISO 8601 (subset) year-month";
+        } else {
+            Class<? extends Temporal> normTemporalClass =
+                    
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+            if (normTemporalClass != temporalClass) {
+                return getXSFormatter(normTemporalClass, timeZone);
+            } else {
+                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+                description = "ISO 8601 (subset) date-time";
+            }
+        }
+        // TODO [FREEMARKER-35] What about date-only?
+        return new 
ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), 
description);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
new file mode 100644
index 0000000..001b870
--- /dev/null
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -0,0 +1,129 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.chrono.IsoChronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.FormatStyle;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateTemporalModel;
+
+final class JavaTemplateTemporalFormat extends 
BaseJavaTemplateTemporalFormatTemplateFormat {
+    private static final Pattern FORMAT_STYLE_PATTERN = 
Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$");
+
+    // TODO [FREEMARKER-35] This is not right, but for now we mimic what 
TemporalUtils did
+    private static final DateTimeFormatter SHORT_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
+    private static final DateTimeFormatter MEDIUM_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
+    private static final DateTimeFormatter LONG_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
+    private static final DateTimeFormatter FULL_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL);
+
+    private final String formatString;
+
+    JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone)
+            throws InvalidFormatParametersException {
+        super(getDateTimeFormat(formatString, temporalClass, locale, 
timeZone));
+        this.formatString = formatString;
+    }
+
+    private static DateTimeFormatter getDateTimeFormat(String formatString, 
Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) 
throws
+            InvalidFormatParametersException {
+        DateTimeFormatter result;
+        if (FORMAT_STYLE_PATTERN.matcher(formatString).matches()) {
+            // TODO [FREEMARKER-35] This is not right, but for now we mimic 
what TemporalUtils did
+            boolean isYear = Year.class.isAssignableFrom(temporalClass);
+            boolean isYearMonth = 
YearMonth.class.isAssignableFrom(temporalClass);
+            String[] formatSplt = formatString.split("_");
+            if (isYear || isYearMonth) {
+                String reducedPattern = 
DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()),
 null, IsoChronology.INSTANCE, locale);
+                if (isYear)
+                    result = 
DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false));
+                else
+                    result = 
DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true));
+            } else if ("short".equals(formatString))
+                result =  SHORT_FORMAT;
+            else if ("medium".equals(formatString))
+                result =  MEDIUM_FORMAT;
+            else if ("long".equals(formatString))
+                result =  LONG_FORMAT;
+            else if ("full".equals(formatString))
+                result = FULL_FORMAT;
+            else
+                result = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()),
 FormatStyle.valueOf(formatSplt[1].toUpperCase()));
+        } else {
+            try {
+                result = DateTimeFormatter.ofPattern(formatString);
+            } catch (IllegalArgumentException e) {
+                throw new InvalidFormatParametersException(e.getMessage(), e);
+            }
+        }
+        return result.withLocale(locale).withZone(timeZone.toZoneId());
+    }
+
+    // TODO [FREEMARKER-35] This override should be unecessary. Move logic 
here into getDateTimeFormat somehow.
+    @Override
+    public String format(TemplateTemporalModel tm) throws 
TemplateValueFormatException, TemplateModelException {
+        return super.format(tm);
+    }
+
+    @Override
+    public String getDescription() {
+        return formatString;
+    }
+
+    /**
+     * Tells if this formatter should be re-created if the locale changes.
+     */
+    @Override
+    public boolean isLocaleBound() {
+        return true;
+    }
+
+    /**
+     * Tells if this formatter should be re-created if the time zone changes.
+     */
+    @Override
+    public boolean isTimeZoneBound() {
+        return true;
+    }
+
+    // TODO [FREEMARKER-35] This is not right, but for now we mimic what 
TemporalUtils did
+    private static String removeNonYM(String pattern, boolean withMonth) {
+        boolean separator = false;
+        boolean copy = true;
+        StringBuilder newPattern = new StringBuilder();
+        for (char c : pattern.toCharArray()) {
+            if (c == '\'')
+                separator = !separator;
+            if (!separator && Character.isAlphabetic(c))
+                copy = c == 'y' || c == 'u' || (withMonth && (c == 'M' || c == 
'L'));
+            if (copy)
+                newPattern.append(c);
+        }
+        return newPattern.toString();
+    }
+
+}
diff --git 
a/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..5e92a89
--- /dev/null
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java
@@ -0,0 +1,42 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+class JavaTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+    public static final JavaTemplateTemporalFormatFactory INSTANCE = new 
JavaTemplateTemporalFormatFactory();
+
+    private JavaTemplateTemporalFormatFactory() {
+        // Not instantiated from outside
+    }
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone,
+            Environment env) throws TemplateValueFormatException {
+        return new JavaTemplateTemporalFormat(params, temporalClass, locale, 
timeZone);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java 
b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index aec3844..9fa51ef 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -18,45 +18,35 @@
  */
 package freemarker.core;
 
-import java.util.Locale;
-import java.util.TimeZone;
+import java.time.format.DateTimeFormatter;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.TemporalUtil;
 
-public class TemplateTemporalFormat extends TemplateValueFormat {
-    private final String format;
-    private final Locale locale;
-    private final TimeZone timeZone;
-
-    public TemplateTemporalFormat(String format, Locale locale, TimeZone 
timeZone) {
-        this.format = format;
-        this.locale = locale;
-        this.timeZone = timeZone;
-    }
-
-    public String format(TemplateTemporalModel temporalModel) throws 
TemplateValueFormatException, TemplateModelException {
-        return TemporalUtil.format(temporalModel.getAsTemporal(), format, 
locale, timeZone);
-    }
+/**
+ * Represents a {@link Temporal} format; used in templates for formatting and 
parsing with that format. This is
+ * similar to Java's {@link DateTimeFormatter}, but made to fit the 
requirements of FreeMarker. Also, it makes easier to
+ * define formats that can't be represented with {@link DateTimeFormatter}.
+ *
+ * <p>
+ * Implementations need not be thread-safe if the {@link 
TemplateTemporalFormatFactory} doesn't recycle them among
+ * different {@link Environment}-s. As far as FreeMarker's concerned, 
instances are bound to a single
+ * {@link Environment}, and {@link Environment}-s are thread-local objects.
+ *
+ * @since 2.3.31
+ */
+public abstract class TemplateTemporalFormat extends TemplateValueFormat {
 
-    @Override
-    public String getDescription() {
-        return format + " " + locale.toString();
-    }
+    public abstract String format(TemplateTemporalModel temporalModel) throws 
TemplateValueFormatException, TemplateModelException;
 
     /**
      * Tells if this formatter should be re-created if the locale changes.
      */
-    public boolean isLocaleBound() {
-        return true;
-    }
+    public abstract boolean isLocaleBound();
 
     /**
      * Tells if this formatter should be re-created if the time zone changes.
      */
-    public boolean isTimeZoneBound() {
-        return true;
-    }
+    public abstract boolean isTimeZoneBound();
 
 }
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..ae3971b
--- /dev/null
+++ b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java
@@ -0,0 +1,81 @@
+/*
+ * 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 freemarker.core;
+
+import java.text.SimpleDateFormat;
+import java.time.temporal.Temporal;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import freemarker.template.Configuration;
+import freemarker.template.TemplateDateModel;
+
+/**
+ * Factory for a certain kind of {@link Temporal} formatting ({@link 
TemplateTemporalFormat}). Usually a singleton
+ * (one-per-VM or one-per-{@link Configuration}), and so must be thread-safe.
+ * 
+ * TODO [FREEMARKER-35] @see 
Configurable#setCustomTemporalFormats(java.util.Map)
+ * 
+ * @since 2.3.24
+ */
+public abstract class TemplateTemporalFormatFactory extends 
TemplateValueFormatFactory {
+    
+    /**
+     * Returns a formatter for the given parameters.
+     * 
+     * <p>
+     * The returned formatter can be a new instance or a reused (cached) 
instance. Note that {@link Environment} itself
+     * caches the returned instances, though that cache is lost with the 
{@link Environment} (i.e., when the top-level
+     * template execution ends), also it might flushes lot of entries if the 
locale or time zone is changed during
+     * template execution. So caching on the factory level is still useful, 
unless creating the formatters is
+     * sufficiently cheap.
+     * 
+     * @param params
+     *            The string that further describes how the format should 
look. For example, when the
+     *            {@link Configurable#getInstantFormat()} ()} instantFormat} 
is {@code "@fooBar 1, 2"}, then it will be
+     *            {@code "1, 2"} (and {@code "@fooBar"} selects the factory). 
The format of this string is up to the
+     *            {@link TemplateTemporalFormatFactory} implementation. Not 
{@code null}, often an empty string.
+     * @param temporalClass
+     *            The type of the temporal. If this type is not supported, the 
method should throw an
+     *            {@link UnformattableTemporalTypeException} exception.
+     * @param locale
+     *            The locale to format for. Not {@code null}. The resulting 
format should be bound to this locale
+     *            forever (i.e. locale changes in the {@link Environment} must 
not be followed).
+     * @param timeZone
+     *            The time zone to format for. Not {@code null}. The resulting 
format must be bound to this time zone
+     *            forever (i.e. time zone changes in the {@link Environment} 
must not be followed).
+     * @param env
+     *            The runtime environment from which the formatting was 
called. This is mostly meant to be used for
+     *            {@link Environment#setCustomState(Object, Object)}/{@link 
Environment#getCustomState(Object)}. The
+     *            result shouldn't depend on setting values in the {@link 
Environment}, as changing other setting
+     *            will not necessarily invalidate the result.
+     * 
+     * @throws TemplateValueFormatException
+     *             If any problem occurs while parsing/getting the format. 
Notable subclasses:
+     *             {@link InvalidFormatParametersException} if {@code params} 
is malformed;
+     *             {@link UnformattableTemporalTypeException} if the {@code 
temporalClass} subclass is
+     *             not supported by this factory.
+     */
+    public abstract TemplateTemporalFormat get(
+            String params,
+            Class<? extends Temporal> temporalClass, Locale locale, TimeZone 
timeZone, Environment env)
+                    throws TemplateValueFormatException;
+
+}
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java 
b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
similarity index 59%
copy from src/main/java/freemarker/core/TemplateTemporalFormat.java
copy to src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
index aec3844..3bd6d64 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java
@@ -16,47 +16,49 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+
 package freemarker.core;
 
-import java.util.Locale;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.temporal.Temporal;
+import java.util.Objects;
 import java.util.TimeZone;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.TemporalUtil;
 
-public class TemplateTemporalFormat extends TemplateValueFormat {
-    private final String format;
-    private final Locale locale;
-    private final TimeZone timeZone;
+class ToStringTemplateTemporalFormat extends TemplateTemporalFormat {
 
-    public TemplateTemporalFormat(String format, Locale locale, TimeZone 
timeZone) {
-        this.format = format;
-        this.locale = locale;
-        this.timeZone = timeZone;
-    }
+    private final ZoneId timeZone;
 
-    public String format(TemplateTemporalModel temporalModel) throws 
TemplateValueFormatException, TemplateModelException {
-        return TemporalUtil.format(temporalModel.getAsTemporal(), format, 
locale, timeZone);
+    ToStringTemplateTemporalFormat(TimeZone timeZone) {
+        this.timeZone = timeZone.toZoneId();
     }
 
     @Override
-    public String getDescription() {
-        return format + " " + locale.toString();
+    public String format(TemplateTemporalModel temporalModel) throws 
TemplateValueFormatException,
+            TemplateModelException {
+        Temporal temporal = 
TemplateFormatUtil.getNonNullTemporal(temporalModel);
+        // TODO [FREEMARKER-35] This is not right, but for now we mimic what 
TemporalUtils did
+        if (temporal instanceof Instant) {
+            temporal = ((Instant) temporal).atZone(timeZone);
+        }
+        return temporal.toString();
     }
 
-    /**
-     * Tells if this formatter should be re-created if the locale changes.
-     */
+    @Override
     public boolean isLocaleBound() {
-        return true;
+        return false;
     }
 
-    /**
-     * Tells if this formatter should be re-created if the time zone changes.
-     */
+    @Override
     public boolean isTimeZoneBound() {
         return true;
     }
 
+    @Override
+    public String getDescription() {
+        return "toString()";
+    }
 }
diff --git 
a/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..951471e
--- /dev/null
+++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java
@@ -0,0 +1,42 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+class ToStringTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
+
+    static final ToStringTemplateTemporalFormatFactory INSTANCE = new 
ToStringTemplateTemporalFormatFactory();
+
+    private ToStringTemplateTemporalFormatFactory() {
+        // Not meant to be called from outside
+    }
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
+            TemplateValueFormatException {
+        if (!params.isEmpty()) {
+            throw new InvalidFormatParametersException("toString format 
doesn't support parameters");
+        }
+        return new ToStringTemplateTemporalFormat(timeZone);
+    }
+}
diff --git 
a/src/main/java/freemarker/core/UnformattableTemporalTypeException.java 
b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java
new file mode 100644
index 0000000..a4f377a
--- /dev/null
+++ b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java
@@ -0,0 +1,38 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.temporal.Temporal;
+
+import freemarker.template.TemplateTemporalModel;
+
+/**
+ * Thrown when a {@link TemplateTemporalModel} can't be formatted because the 
{@link TemplateTemporalFormatFactory}
+ * doesn't support it.
+ *
+ * @since 2.3.31
+ */
+public final class UnformattableTemporalTypeException extends 
UnformattableValueException {
+
+    public UnformattableTemporalTypeException(Class<? extends Temporal> 
temporalClass) {
+        super("Temporal type not supported: " + temporalClass.getName());
+    }
+    
+}
diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
new file mode 100644
index 0000000..1f36313
--- /dev/null
+++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
@@ -0,0 +1,124 @@
+/*
+ * 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 freemarker.core;
+
+import static freemarker.core.ISOTemplateTemporalFormatFactory.*;
+
+import java.time.LocalTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
+
+    static final XSTemplateTemporalFormatFactory INSTANCE = new 
XSTemplateTemporalFormatFactory();
+
+    private XSTemplateTemporalFormatFactory() {
+        // Not meant to be called from outside
+    }
+
+    private final static DateTimeFormatter XSD_DATE_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .optionalStart()
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalEnd()
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private final static DateTimeFormatter XSD_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    private static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendLiteral("-")
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .optionalStart()
+            .appendOffsetId()
+            .optionalEnd()
+            .toFormatter()
+            .withZone(ZoneOffset.UTC)
+            .withLocale(Locale.US);
+
+    @Override
+    public TemplateTemporalFormat get(String params, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
+            TemplateValueFormatException {
+        if (!params.isEmpty()) {
+            // TODO [FREEMARKER-35]
+            throw new InvalidFormatParametersException("xs currently doesn't 
support parameters");
+        }
+
+        return getXSFormatter(temporalClass, timeZone.toZoneId());
+    }
+
+    private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? 
extends Temporal> temporalClass, ZoneId timeZone) {
+        final DateTimeFormatter dateTimeFormatter;
+        final String description;
+        if (temporalClass == LocalTime.class) {
+            dateTimeFormatter = XSD_TIME_FORMAT;
+            description = "W3C XML Schema time";
+        } else if (temporalClass == Year.class) {
+            dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO
+            description = "W3C XML Schema year";
+        } else if (temporalClass == YearMonth.class) {
+            dateTimeFormatter = XSD_YEARMONTH_FORMAT;
+            description = "W3C XML Schema year-month";
+        } else {
+            Class<? extends Temporal> normTemporalClass =
+                    
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+            if (normTemporalClass != temporalClass) {
+                return getXSFormatter(normTemporalClass, timeZone);
+            } else {
+                dateTimeFormatter = XSD_DATE_TIME_FORMAT;
+                description = "W3C XML Schema date-time";
+            }
+        }
+        // TODO [FREEMARKER-35] What about date-only?
+        return new 
ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), 
description);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/_CoreTemporalUtils.java 
b/src/main/java/freemarker/core/_CoreTemporalUtils.java
index 027c13d..f95906c 100644
--- a/src/main/java/freemarker/core/_CoreTemporalUtils.java
+++ b/src/main/java/freemarker/core/_CoreTemporalUtils.java
@@ -28,12 +28,15 @@ import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
+import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.temporal.Temporal;
 import java.util.Arrays;
 import java.util.List;
 import java.util.stream.Stream;
 
+import freemarker.template.Configuration;
+
 /**
  * For internal use only; don't depend on this, there's no backward 
compatibility guarantee at all!
  * This class is to work around the lack of module system in Java, i.e., so 
that other FreeMarker packages can
@@ -85,15 +88,44 @@ public class _CoreTemporalUtils {
                 return OffsetDateTime.class;
             } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
                 return OffsetTime.class;
-            } else if (Year.class.isAssignableFrom(temporalClass)) {
-                return Year.class;
-            } else if (YearMonth.class.isAssignableFrom(temporalClass)) {
-                return YearMonth.class;
             } else if (ZonedDateTime.class.isAssignableFrom(temporalClass)) {
                 return ZonedDateTime.class;
+            } else if (YearMonth.class.isAssignableFrom(temporalClass)) {
+                return YearMonth.class;
+            } else if (Year.class.isAssignableFrom(temporalClass)) {
+                return Year.class;
             } else {
                 return temporalClass;
             }
         }
     }
+
+    /**
+     * @throws IllegalArgumentException If {@link temporalClass} is not a 
supported {@link Temporal} subclass.
+     */
+    public static String temporalClassToFormatSettingName(Class<? extends 
Temporal> temporalClass) {
+        temporalClass = normalizeSupportedTemporalClass(temporalClass);
+        if (temporalClass == Instant.class) {
+            return Configuration.INSTANT_FORMAT_KEY;
+        } else if (temporalClass == LocalDate.class) {
+            return Configuration.LOCAL_DATE_FORMAT_KEY;
+        } else if (temporalClass == LocalDateTime.class) {
+            return Configuration.LOCAL_DATE_TIME_FORMAT_KEY;
+        } else if (temporalClass == LocalTime.class) {
+            return Configuration.LOCAL_TIME_FORMAT_KEY;
+        } else if (temporalClass == OffsetDateTime.class) {
+            return Configuration.OFFSET_DATE_TIME_FORMAT_KEY;
+        } else if (temporalClass == OffsetTime.class) {
+            return Configuration.OFFSET_TIME_FORMAT_KEY;
+        } else if (temporalClass == ZonedDateTime.class) {
+            return Configuration.ZONED_DATE_TIME_FORMAT_KEY;
+        } else if (temporalClass == YearMonth.class) {
+            return Configuration.YEAR_MONTH_FORMAT_KEY;
+        } else if (temporalClass == Year.class) {
+            return Configuration.YEAR_FORMAT_KEY;
+        } else {
+            throw new IllegalArgumentException("Unsupported temporal class: " 
+ temporalClass.getName());
+        }
+    }
+    
 }
diff --git a/src/main/java/freemarker/core/_MessageUtil.java 
b/src/main/java/freemarker/core/_MessageUtil.java
index 7867f66..034ec0f 100644
--- a/src/main/java/freemarker/core/_MessageUtil.java
+++ b/src/main/java/freemarker/core/_MessageUtil.java
@@ -322,10 +322,10 @@ public class _MessageUtil {
                 : new _MiscTemplateException(e, null, desc);
     }
     
-    public static TemplateException 
newCantFormatDateException(TemplateTemporalFormat format, Expression dataSrcExp,
+    public static TemplateException 
newCantFormatTemporalException(TemplateTemporalFormat format, Expression 
dataSrcExp,
             TemplateValueFormatException e, boolean useTempModelExc) {
         _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder(
-                "Failed to format date/time/datetime with format ", new 
_DelayedJQuote(format.getDescription()), ": ",
+                "Failed to format temporal value with format ", new 
_DelayedJQuote(format.getDescription()), ": ",
                 e.getMessage())
                 .blame(dataSrcExp);
         return useTempModelExc
diff --git a/src/main/java/freemarker/template/utility/TemporalUtil.java 
b/src/main/java/freemarker/template/utility/TemporalUtil.java
deleted file mode 100644
index a1c9d73..0000000
--- a/src/main/java/freemarker/template/utility/TemporalUtil.java
+++ /dev/null
@@ -1,197 +0,0 @@
-/*
- * 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 freemarker.template.utility;
-
-import java.lang.reflect.Modifier;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.time.OffsetDateTime;
-import java.time.OffsetTime;
-import java.time.Year;
-import java.time.YearMonth;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
-import java.time.chrono.IsoChronology;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeFormatterBuilder;
-import java.time.format.FormatStyle;
-import java.time.temporal.ChronoField;
-import java.time.temporal.Temporal;
-import java.util.Locale;
-import java.util.TimeZone;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
-public class TemporalUtil {
-       private static final Pattern FORMAT_STYLE_PATTERN = 
Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$");
-       private final static DateTimeFormatter SHORT_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
-       private final static DateTimeFormatter MEDIUM_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
-       private final static DateTimeFormatter LONG_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
-       private final static DateTimeFormatter FULL_FORMAT = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL);
-
-       private final static DateTimeFormatter XSD_FORMAT = new 
DateTimeFormatterBuilder()
-                       .append(DateTimeFormatter.ISO_LOCAL_DATE)
-                       .optionalStart()
-                       .appendLiteral('T')
-                       .appendValue(ChronoField.HOUR_OF_DAY, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-                       .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-                       .optionalEnd()
-                               .optionalStart()
-                       .appendOffsetId()
-                       .optionalEnd()
-                       .toFormatter();
-       private final static DateTimeFormatter XSD_TIME_FORMAT = new 
DateTimeFormatterBuilder()
-                       .appendValue(ChronoField.HOUR_OF_DAY, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-                       .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-                       .optionalStart()
-                               .appendOffsetId()
-                       .optionalEnd()
-                       .toFormatter();
-       public static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new 
DateTimeFormatterBuilder()
-                       .appendValue(ChronoField.YEAR)
-                       .appendLiteral("-")
-                       .appendValue(ChronoField.MONTH_OF_YEAR, 2)
-                       .optionalStart()
-                               .appendOffsetId()
-                       .optionalEnd()
-                       .toFormatter();
-
-       public static final DateTimeFormatter ISO8601_FORMAT = new 
DateTimeFormatterBuilder()
-                       .append(DateTimeFormatter.ISO_LOCAL_DATE)
-                       .optionalStart()
-                               .appendLiteral('T')
-                               .appendValue(ChronoField.HOUR_OF_DAY, 2)
-                               .appendLiteral(":")
-                               .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-                               .appendLiteral(":")
-                               .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-                               .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 
3, true)
-                               .optionalStart()
-                                       .appendOffsetId()
-                               .optionalEnd()
-                       .optionalEnd()
-                       .toFormatter();
-       public static final DateTimeFormatter ISO8601_TIME_FORMAT = new 
DateTimeFormatterBuilder()
-                       .appendValue(ChronoField.HOUR_OF_DAY, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
-                       .appendLiteral(":")
-                       .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-                       .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
-                       .toFormatter();
-       public static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new 
DateTimeFormatterBuilder()
-                       .appendValue(ChronoField.YEAR)
-                       .appendLiteral("-")
-                       .appendValue(ChronoField.MONTH_OF_YEAR, 2)
-                       .toFormatter();
-       public static final DateTimeFormatter ISO8601_YEAR_FORMAT = new 
DateTimeFormatterBuilder()
-                       .appendValue(ChronoField.YEAR)
-                       .toFormatter();
-
-       private static DateTimeFormatter getISO8601Formatter(Temporal temporal) 
{
-               if (temporal instanceof LocalTime)
-                       return ISO8601_TIME_FORMAT;
-               else if (temporal instanceof Year)
-                       return ISO8601_YEAR_FORMAT;
-               else if (temporal instanceof YearMonth)
-                       return ISO8601_YEARMONTH_FORMAT;
-               else
-                       return ISO8601_FORMAT;
-       }
-
-       private static DateTimeFormatter getXSFormatter(Temporal temporal) {
-               if (temporal instanceof LocalTime)
-                       return XSD_TIME_FORMAT;
-               else if (temporal instanceof Year)
-                       return ISO8601_YEAR_FORMAT;//ISO same as XSD here
-               else if (temporal instanceof YearMonth)
-                       return XSD_YEARMONTH_FORMAT;
-               else
-                       return XSD_FORMAT;
-       }
-
-       public static String format(Temporal temporal, String format, Locale 
locale, TimeZone timeZone) {
-               //TODO: cache these DateTimeFormatter instances (withLocale & 
withZone create new instances too, when they differ from the instance)
-               if (temporal instanceof Instant)
-                       temporal = ((Instant) temporal).atZone(timeZone == null 
? ZoneOffset.UTC : timeZone.toZoneId());
-
-               DateTimeFormatter dtf;
-               if ("xs".equals(format))
-                       dtf = getXSFormatter(temporal);
-               else if ("iso".equals(format))
-                       dtf =  getISO8601Formatter(temporal);
-               else if (FORMAT_STYLE_PATTERN.matcher(format).matches()) {
-                       boolean isYear = temporal instanceof Year;
-                       boolean isYearMonth = temporal instanceof YearMonth;
-                       String[] formatSplt = format.split("_");
-                       if (isYear || isYearMonth) {
-                               String reducedPattern = 
DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()),
 null, IsoChronology.INSTANCE, locale);
-                               if (isYear)
-                                       dtf = 
DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false));
-                               else
-                                       dtf = 
DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true));
-                       } else if ("short".equals(format))
-                               dtf =  SHORT_FORMAT;
-                       else if ("medium".equals(format))
-                               dtf =  MEDIUM_FORMAT;
-                       else if ("long".equals(format))
-                               dtf =  LONG_FORMAT;
-                       else if ("full".equals(format))
-                               dtf = FULL_FORMAT;
-                       else
-                               dtf = 
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()),
 FormatStyle.valueOf(formatSplt[1].toUpperCase()));
-               } else if (!"".equals(format))
-                       dtf = DateTimeFormatter.ofPattern(format);
-               else
-                       return temporal.toString();
-
-               dtf = dtf.withLocale(locale);
-               if (temporal instanceof OffsetDateTime)
-                       dtf = dtf.withZone(((OffsetDateTime) 
temporal).getOffset());
-               else if (!(temporal instanceof ZonedDateTime))
-                       dtf = dtf.withZone(timeZone.toZoneId());
-               return dtf.format(temporal);
-       }
-
-       private static String removeNonYM(String pattern, boolean withMonth) {
-               boolean separator = false;
-               boolean copy = true;
-               StringBuilder newPattern = new StringBuilder();
-               for (char c : pattern.toCharArray()) {
-                       if (c == '\'')
-                               separator = !separator;
-                       if (!separator && Character.isAlphabetic(c))
-                               copy = c == 'y' || c == 'u' || (withMonth && (c 
== 'M' || c == 'L'));
-                       if (copy)
-                               newPattern.append(c);
-               }
-               return newPattern.toString();
-       }
-
-}
diff --git a/src/test/java/freemarker/core/TemporalConfigurableTest.java 
b/src/test/java/freemarker/core/CoreTemporalUtilTest.java
similarity index 67%
rename from src/test/java/freemarker/core/TemporalConfigurableTest.java
rename to src/test/java/freemarker/core/CoreTemporalUtilTest.java
index 92cb575..2eab1f5 100644
--- a/src/test/java/freemarker/core/TemporalConfigurableTest.java
+++ b/src/test/java/freemarker/core/CoreTemporalUtilTest.java
@@ -21,15 +21,16 @@ package freemarker.core;
 
 import static org.junit.Assert.*;
 
-import java.time.Instant;
 import java.time.chrono.ChronoLocalDate;
 import java.time.temporal.Temporal;
+import java.util.HashSet;
+import java.util.Set;
 
 import org.junit.Test;
 
 import freemarker.template.Configuration;
 
-public class TemporalConfigurableTest {
+public class CoreTemporalUtilTest {
 
     @Test
     public void testSupportedTemporalClassAreFinal() {
@@ -42,12 +43,31 @@ public class TemporalConfigurableTest {
     @Test
     public void testGetTemporalFormat() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
+
         for (Class<? extends Temporal> supportedTemporalClass : 
_CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
             assertNotNull(cfg.getTemporalFormat(supportedTemporalClass));
         }
 
         try {
-            assertNotNull(cfg.getTemporalFormat(ChronoLocalDate.class));
+            cfg.getTemporalFormat(ChronoLocalDate.class);
+            fail();
+        } catch (IllegalArgumentException e) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testTemporalClassToFormatSettingName() {
+        Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
+
+        Set<String> uniqueSettingNames = new HashSet<>();
+        for (Class<? extends Temporal> supportedTemporalClass : 
_CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+            
assertTrue(uniqueSettingNames.add(_CoreTemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass)));
+        }
+        assertTrue(uniqueSettingNames.stream().allMatch(it -> 
cfg.getSettingNames(false).contains(it)));
+
+        try {
+            
_CoreTemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class);
             fail();
         } catch (IllegalArgumentException e) {
             // Expected
diff --git a/src/test/java/freemarker/core/TemporalErrorMessagesTest.java 
b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java
new file mode 100644
index 0000000..e9c2791
--- /dev/null
+++ b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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 freemarker.core;
+
+import java.time.Instant;
+import java.time.LocalTime;
+
+import org.junit.Test;
+
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+public class TemporalErrorMessagesTest extends TemplateTest {
+
+    @Test
+    public void testExplicitFormatString() throws TemplateException {
+        addToDataModel("t", LocalTime.now());
+        assertErrorContains("${t?string('yyyy-HH')}", "Failed to format 
temporal value", "yyyy-HH", "YearOfEra");
+    }
+
+    @Test
+    public void testDefaultFormatStringBadFormatString() throws 
TemplateException {
+        getConfiguration().setSetting("local_time_format", "ABCDEF");
+        addToDataModel("t", LocalTime.now());
+        assertErrorContains("${t}", "local_time_format", "ABCDEF");
+        assertErrorContains("${t?string}", "local_time_format", "ABCDEF");
+    }
+
+    @Test
+    public void testDefaultFormatStringIncompatibleFormatString() throws 
TemplateException {
+        getConfiguration().setSetting("local_time_format", "yyyy-HH");
+        addToDataModel("t", LocalTime.now());
+        // TODO [FREEMARKER-35] Should contain "local_time_format" too
+        assertErrorContains("${t}", "Failed to format temporal value", 
"yyyy-HH", "YearOfEra");
+        assertErrorContains("${t?string}", "Failed to format temporal value", 
"yyyy-HH", "YearOfEra");
+    }
+
+}
diff --git a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java 
b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
index 4522026..1ab62ab 100644
--- a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
+++ b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java
@@ -27,6 +27,7 @@ import java.math.BigDecimal;
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
+import java.time.OffsetTime;
 import java.time.Year;
 import java.time.YearMonth;
 import java.time.ZoneId;
@@ -365,6 +366,7 @@ public class TemplateTestCase extends FileTestCase {
             dataModel.put("yearMonth", YearMonth.from(ldt));
             ZonedDateTime zdt = ldt.atZone(ZoneId.of("UTC"));
             dataModel.put("offsetDateTime", zdt.toOffsetDateTime());
+            dataModel.put("offsetTime", zdt.toOffsetDateTime().toOffsetTime());
             dataModel.put("zonedDateTime", zdt);
         } else if (simpleTestName.equals("var-layers")) {
             dataModel.put("x", Integer.valueOf(4));
diff --git 
a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl 
b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
index 72d44ad..cbc5fd1 100644
--- a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
+++ b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
@@ -21,6 +21,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string />
 <@assertEquals expected="06:07:08" actual=localTime?string />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string />
 <@assertEquals expected="2003-04" actual=yearMonth?string />
 <@assertEquals expected="2003-04-05T06:07:08Z[UTC]" 
actual=zonedDateTime?string />
@@ -31,6 +32,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.iso />
 <@assertEquals expected="06:07:08" actual=localTime?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" 
actual=offsetDateTime?string.iso />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.iso />
 <@assertEquals expected="2003-04" actual=yearMonth?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso 
/>
@@ -41,6 +43,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.iso />
 <@assertEquals expected="06:07:08" actual=localTime?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" 
actual=offsetDateTime?string.iso />
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.iso />
 <@assertEquals expected="2003-04" actual=yearMonth?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso 
/>
@@ -50,6 +53,7 @@
 <@assertEquals expected="2003-04-05" actual=localDate?string.xs />
 <@assertEquals expected="06:07:08" actual=localTime?string.xs />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.xs 
/>
+<@assertEquals expected="06:07:08Z" actual=offsetTime?string />
 <@assertEquals expected="2003" actual=year?string.xs />
 <@assertEquals expected="2003-04" actual=yearMonth?string.xs />
 <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.xs 
/>

Reply via email to