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 691dfaba50bb0db363f86b6614c88df626f06160
Author: ddekany <[email protected]>
AuthorDate: Sun Jan 2 23:46:41 2022 +0100

    [FREEMARKER-35] Continued temporal parsing, improved ISO (and XS) 
formatters. Some code cleanup.
---
 src/main/java/freemarker/core/Configurable.java    |   3 +-
 src/main/java/freemarker/core/Environment.java     |   8 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |  74 ++-
 .../core/ISOTemplateTemporalFormatFactory.java     | 172 +++++--
 .../core/JavaTemplateTemporalFormat.java           |  30 +-
 .../core/XSTemplateTemporalFormatFactory.java      |  35 +-
 .../java/freemarker/core/_CoreTemporalUtils.java   | 124 -----
 src/main/java/freemarker/template/Template.java    |   2 +-
 .../java/freemarker/template/utility/DateUtil.java | 328 +-------------
 .../freemarker/template/utility/StringUtil.java    |  10 +-
 .../freemarker/template/utility/TemporalUtils.java | 499 +++++++++++++++++++++
 .../core/AbstractTemporalFormatTest.java           | 131 ++++++
 ...ava => TemporalFormatWithCustomFormatTest.java} |   4 +-
 .../core/TemporalFormatWithIsoFormatTest.java      | 313 +++++++++++++
 ....java => TemporalFormatWithJavaFormatTest.java} | 231 +++++-----
 .../utility/DateUtilsPatternParsingTest.java       |  57 ++-
 .../utility/TemporalUtilsTest.java}                |  25 +-
 17 files changed, 1349 insertions(+), 697 deletions(-)

diff --git a/src/main/java/freemarker/core/Configurable.java 
b/src/main/java/freemarker/core/Configurable.java
index 94c06c3..17fca68 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -82,6 +82,7 @@ import freemarker.template.Version;
 import freemarker.template._TemplateAPI;
 import freemarker.template.utility.NullArgumentException;
 import freemarker.template.utility.StringUtil;
+import freemarker.template.utility.TemporalUtils;
 
 /**
  * This is a common superclass of {@link freemarker.template.Configuration},
@@ -1457,7 +1458,7 @@ public class Configurable {
         } else {
             // Handle the unlikely situation that in some future Java version 
we can have subclasses.
             Class<? extends Temporal> normTemporalClass =
-                    
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+                    
TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
             if (normTemporalClass == temporalClass) {
                 throw new IllegalArgumentException("There's no temporal format 
setting for this class: "
                         + temporalClass.getName());
diff --git a/src/main/java/freemarker/core/Environment.java 
b/src/main/java/freemarker/core/Environment.java
index 24a0137..374bd98 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -82,6 +82,7 @@ import 
freemarker.template.utility.DateUtil.DateToISO8601CalendarFactory;
 import freemarker.template.utility.NullWriter;
 import freemarker.template.utility.StringUtil;
 import freemarker.template.utility.TemplateModelUtils;
+import freemarker.template.utility.TemporalUtils;
 import freemarker.template.utility.UndeclaredThrowableException;
 
 /**
@@ -2342,7 +2343,12 @@ public final class Environment extends Configurable {
             String settingName;
             String settingValue;
             try {
-                settingName = 
_CoreTemporalUtils.temporalClassToFormatSettingName(temporalClass);
+                settingName = TemporalUtils.temporalClassToFormatSettingName(
+                        temporalClass,
+                        blamedTemporalSourceExp != null
+                                ? 
blamedTemporalSourceExp.getTemplate().getActualNamingConvention()
+                                        == 
Configuration.CAMEL_CASE_NAMING_CONVENTION
+                                : false);
                 settingValue = getTemporalFormat(temporalClass);
             } catch (IllegalArgumentException e2) {
                 settingName = "???";
diff --git 
a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java 
b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 00b79f1..482b257 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -19,15 +19,25 @@
 
 package freemarker.core;
 
+import static freemarker.template.utility.StringUtil.*;
+
 import java.time.DateTimeException;
 import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetTime;
+import java.time.Year;
+import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.time.temporal.Temporal;
+import java.time.temporal.TemporalQuery;
 import java.util.TimeZone;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.TemporalUtils;
 
 // TODO [FREEMARKER-35] These should support parameters similar to {@link 
ISOTemplateDateFormat},
 
@@ -41,17 +51,24 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
TemplateTemporalFormat
     private final boolean instantConversion;
     private final ZoneId zoneId;
     private final String description;
+    private final TemporalQuery temporalQuery;
+    private final Class<? extends Temporal> temporalClass;
+    private final DateTimeFormatter parserExtendedDateTimeFormatter;
+    private final DateTimeFormatter parserBasicDateTimeFormatter;
 
-    public ISOLikeTemplateTemporalTemporalFormat(
-            DateTimeFormatter dateTimeFormatter, Class<? extends Temporal> 
temporalClass, TimeZone zone, String description) {
+    ISOLikeTemplateTemporalTemporalFormat(
+            DateTimeFormatter dateTimeFormatter,
+            DateTimeFormatter parserExtendedDateTimeFormatter,
+            DateTimeFormatter parserBasicDateTimeFormatter,
+            Class<? extends Temporal> temporalClass, TimeZone zone, String 
formatString) {
         this.dateTimeFormatter = dateTimeFormatter;
+        this.parserExtendedDateTimeFormatter = parserExtendedDateTimeFormatter;
+        this.parserBasicDateTimeFormatter = parserBasicDateTimeFormatter;
+        this.temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
         this.instantConversion = Instant.class.isAssignableFrom(temporalClass);
-        if (instantConversion) {
-            zoneId = zone.toZoneId();
-        } else {
-            zoneId = null;
-        }
-        this.description = description;
+        this.temporalClass = temporalClass;
+        this.zoneId = zone.toZoneId();
+        this.description = formatString;
     }
 
     @Override
@@ -72,7 +89,46 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
TemplateTemporalFormat
 
     @Override
     public Object parse(String s) throws TemplateValueFormatException {
-        throw new ParsingNotSupportedException("To be implemented"); // TODO 
[FREEMARKER-35]
+        DateTimeFormatter parserDateTimeFormatter = 
parserBasicDateTimeFormatter == null || isExtendedFormatString(s)
+                ? parserExtendedDateTimeFormatter : 
parserBasicDateTimeFormatter;
+        try {
+            return parserDateTimeFormatter.parse(s, temporalQuery);
+        } catch (DateTimeParseException e) {
+            throw new UnparsableValueException(
+                    "Failed to parse value " + jQuote(s) + " with format " + 
jQuote(description)
+                            + ", and target class " + 
temporalClass.getSimpleName() + ", "
+                            + "zoneId " + jQuote(zoneId) + ".\n"
+                            + "(Used this DateTimeFormatter: " + 
parserDateTimeFormatter + ")\n"
+                            + "(Root cause message: " + e.getMessage() + ")",
+                    e);
+        }
+    }
+
+    private boolean isExtendedFormatString(String s) throws 
UnparsableValueException {
+        if (temporalClass == LocalDate.class || temporalClass == 
YearMonth.class) {
+            return !s.isEmpty() && s.indexOf('-', 1) != -1;
+        } else if (temporalClass == LocalTime.class || temporalClass == 
OffsetTime.class) {
+            return s.indexOf(":") != -1;
+        } else if (temporalClass == Year.class) {
+            return false;
+        } else {
+            int tIndex = s.indexOf('T');
+            if (tIndex < 1) {
+                throw new UnparsableValueException(
+                        "Failed to parse value " + jQuote(s) + " with format " 
+ jQuote(description)
+                                + ", and target class " + 
temporalClass.getSimpleName() + ": "
+                                + "Character \"T\" must be used to separate 
the date and time part.");
+            }
+            if (s.indexOf(":", tIndex + 1) != -1) {
+                return true;
+            }
+            // Note: false for: -5000101T00, as there the last '-' has index 0
+            return s.lastIndexOf('-', tIndex - 1) > 0;
+        }
+    }
+
+    private boolean temporalClassHasNoTimePart() {
+        return temporalClass == LocalDate.class || temporalClass == Year.class 
|| temporalClass == YearMonth.class;
     }
 
     @Override
diff --git 
a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
index 50edb2c..48b48a5 100644
--- a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
@@ -19,18 +19,29 @@
 
 package freemarker.core;
 
+import static java.time.temporal.ChronoField.*;
+
+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.ZonedDateTime;
+import java.time.chrono.IsoChronology;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.ResolverStyle;
+import java.time.format.SignStyle;
 import java.time.temporal.ChronoField;
 import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
+import freemarker.template.utility.TemporalUtils;
+
 /**
  * Format factory related to {@link someJava8Temporal?string.iso}, {@link 
someJava8Temporal?string.iso_...}, etc.
  */
@@ -44,8 +55,9 @@ class ISOTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
 
     static final DateTimeFormatter ISO8601_DATE_FORMAT = new 
DateTimeFormatterBuilder()
             .append(DateTimeFormatter.ISO_LOCAL_DATE)
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_DATE_TIME_FORMAT = new 
DateTimeFormatterBuilder()
             .append(DateTimeFormatter.ISO_LOCAL_DATE)
@@ -55,12 +67,13 @@ class ISOTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
             .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
             .appendLiteral(":")
             .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
             .optionalStart()
-            .appendOffsetId()
+            .appendOffset("+HH:MM", "Z")
             .optionalEnd()
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_TIME_FORMAT = new 
DateTimeFormatterBuilder()
             .appendValue(ChronoField.HOUR_OF_DAY, 2)
@@ -68,24 +81,120 @@ class ISOTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
             .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
             .appendLiteral(":")
             .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
-            .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
             .optionalStart()
-            .appendOffsetId()
+            .appendOffset("+HH:MM", "Z")
             .optionalEnd()
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
-    static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new 
DateTimeFormatterBuilder()
+    static final DateTimeFormatter ISO8601_YEAR_MONTH_FORMAT = new 
DateTimeFormatterBuilder()
             .appendValue(ChronoField.YEAR)
             .appendLiteral("-")
             .appendValue(ChronoField.MONTH_OF_YEAR, 2)
-            .toFormatter()
-            .withLocale(Locale.US);
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     static final DateTimeFormatter ISO8601_YEAR_FORMAT = new 
DateTimeFormatterBuilder()
             .appendValue(ChronoField.YEAR)
             .toFormatter()
-            .withLocale(Locale.US);
+            .withLocale(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT = 
new DateTimeFormatterBuilder()
+            .append(DateTimeFormatter.ISO_LOCAL_DATE)
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HH:mm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_DATE_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
+            .appendValue(MONTH_OF_YEAR, 2)
+            .appendValue(DAY_OF_MONTH, 2)
+            .appendLiteral('T')
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HHmm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_DATE_FORMAT = 
ISO8601_DATE_FORMAT;
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_DATE_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
+            .appendValue(MONTH_OF_YEAR, 2)
+            .appendValue(DAY_OF_MONTH, 2)
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendLiteral(":")
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HH:mm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_TIME_FORMAT = new 
DateTimeFormatterBuilder()
+            .appendValue(ChronoField.HOUR_OF_DAY, 2)
+            .optionalStart()
+            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+            .optionalStart()
+            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .optionalEnd()
+            .optionalStart()
+            .appendOffset("+HHmm", "Z")
+            .optionalEnd()
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
+
+    static final DateTimeFormatter PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT = 
ISO8601_YEAR_MONTH_FORMAT;
+    static final DateTimeFormatter PARSER_ISO8601_BASIC_YEAR_MONTH_FORMAT = 
new DateTimeFormatterBuilder()
+            .appendValue(ChronoField.YEAR)
+            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+            .toFormatter(Locale.ROOT)
+            .withChronology(IsoChronology.INSTANCE)
+            .withResolverStyle(ResolverStyle.STRICT);
 
     @Override
     public TemplateTemporalFormat get(String params, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone, Environment env) throws
@@ -100,31 +209,44 @@ class ISOTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
 
     private static ISOLikeTemplateTemporalTemporalFormat 
getISOFormatter(Class<? extends Temporal> temporalClass, TimeZone timeZone) {
         final DateTimeFormatter dateTimeFormatter;
+        final DateTimeFormatter parserExtendedDateTimeFormatter;
+        final DateTimeFormatter parserBasicDateTimeFormatter;
         final String description;
+        temporalClass = 
TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == 
OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
+            parserExtendedDateTimeFormatter = 
PARSER_ISO8601_EXTENDED_TIME_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_TIME_FORMAT;
             description = "ISO 8601 (subset) time";
         } else if (temporalClass == Year.class) {
             dateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserExtendedDateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserBasicDateTimeFormatter = null;
             description = "ISO 8601 (subset) year";
         } else if (temporalClass == YearMonth.class) {
-            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            dateTimeFormatter = ISO8601_YEAR_MONTH_FORMAT;
+            parserExtendedDateTimeFormatter = 
PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT;
+            parserBasicDateTimeFormatter = 
PARSER_ISO8601_BASIC_YEAR_MONTH_FORMAT;
             description = "ISO 8601 (subset) year-month";
         } else if (temporalClass == LocalDate.class) {
             dateTimeFormatter = ISO8601_DATE_FORMAT;
+            parserExtendedDateTimeFormatter = 
PARSER_ISO8601_EXTENDED_DATE_FORMAT;
+            parserBasicDateTimeFormatter = PARSER_ISO8601_BASIC_DATE_FORMAT;
             description = "ISO 8601 (subset) date";
+        } else if (temporalClass == LocalDateTime.class || temporalClass == 
OffsetDateTime.class
+                || temporalClass == ZonedDateTime.class || temporalClass == 
Instant.class) {
+            dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+            parserExtendedDateTimeFormatter = 
PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT;
+            parserBasicDateTimeFormatter = 
PARSER_ISO8601_BASIC_DATE_TIME_FORMAT;
+            description = "ISO 8601 (subset) date-time";
         } else {
-            Class<? extends Temporal> normTemporalClass =
-                    
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-            if (normTemporalClass != temporalClass) {
-                return getISOFormatter(normTemporalClass, timeZone);
-            } else {
-                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
-                description = "ISO 8601 (subset) date-time";
-            }
+            throw new BugException();
         }
-        // TODO [FREEMARKER-35] What about date-only?
-        return new ISOLikeTemplateTemporalTemporalFormat(dateTimeFormatter, 
temporalClass, timeZone, description);
+        return new ISOLikeTemplateTemporalTemporalFormat(
+                dateTimeFormatter,
+                parserExtendedDateTimeFormatter,
+                parserBasicDateTimeFormatter,
+                temporalClass, timeZone, description);
     }
 
 }
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 0e979f2..64e2cfd 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -36,12 +36,8 @@ import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
-import java.time.temporal.TemporalAccessor;
 import java.time.temporal.TemporalQuery;
-import java.util.IdentityHashMap;
 import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -49,7 +45,7 @@ import java.util.regex.Pattern;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
-import freemarker.template.utility.DateUtil;
+import freemarker.template.utility.TemporalUtils;
 
 /**
  * See {@link JavaTemplateTemporalFormatFactory}.
@@ -82,20 +78,6 @@ class JavaTemplateTemporalFormat extends 
TemplateTemporalFormat {
     private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile(
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
-    private static final Map<Class<? extends Temporal>, TemporalQuery<? 
extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
-    static {
-        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, 
LocalDateTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, 
JavaTemplateTemporalFormat::offsetDateTimeFrom);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, 
ZonedDateTime::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
-        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
-    }
-
     private final DateTimeFormatter dateTimeFormatter;
     private final TemporalQuery<? extends Temporal> temporalQuery;
     private final ZoneId zoneId;
@@ -106,9 +88,9 @@ class JavaTemplateTemporalFormat extends 
TemplateTemporalFormat {
 
     JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> 
temporalClass, Locale locale, TimeZone timeZone)
             throws InvalidFormatParametersException {
-        this.temporalClass = 
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        this.temporalClass = 
TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
 
-        temporalQuery = 
Objects.requireNonNull(TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass));
+        temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
 
         final Matcher formatStylePatternMatcher = 
FORMAT_STYLE_PATTERN.matcher(formatString);
         final boolean isFormatStyleString = 
formatStylePatternMatcher.matches();
@@ -147,7 +129,7 @@ class JavaTemplateTemporalFormat extends 
TemplateTemporalFormat {
             timePartFormatStyle = null;
 
             try {
-                dateTimeFormatter = 
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
+                dateTimeFormatter = 
TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(formatString, 
locale);
             } catch (IllegalArgumentException e) {
                 throw new InvalidFormatParametersException(e.getMessage(), e);
             }
@@ -342,10 +324,6 @@ class JavaTemplateTemporalFormat extends 
TemplateTemporalFormat {
                 || normalizedTemporalClass == YearMonth.class;
     }
 
-    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor 
temporal) {
-        return ZonedDateTime.from(temporal).toOffsetDateTime();
-    }
-
     private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
         switch (style) {
             case SHORT:
diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java 
b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
index f41c0c5..ab39c97 100644
--- a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
@@ -21,19 +21,26 @@ package freemarker.core;
 
 import static freemarker.core.ISOTemplateTemporalFormatFactory.*;
 
+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.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 
+import freemarker.template.utility.TemporalUtils;
+
 /**
  * Format factory related to {@link someJava8Temporal?string.xs}, {@link 
someJava8Temporal?string.xs_...}, etc.
  */
+// TODO [FREEMARKER-35] Historical date handling compared to ISO
 class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
 
     static final XSTemplateTemporalFormatFactory INSTANCE = new 
XSTemplateTemporalFormatFactory();
@@ -55,30 +62,38 @@ class XSTemplateTemporalFormatFactory extends 
TemplateTemporalFormatFactory {
 
     private static ISOLikeTemplateTemporalTemporalFormat 
getXSFormatter(Class<? extends Temporal> temporalClass, TimeZone timeZone) {
         final DateTimeFormatter dateTimeFormatter;
+        final DateTimeFormatter parserDateTimeFormatter;
         final String description;
+        temporalClass = 
TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == 
OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_TIME_FORMAT;
             description = "W3C XML Schema time";
         } else if (temporalClass == Year.class) {
             dateTimeFormatter = ISO8601_YEAR_FORMAT;
+            parserDateTimeFormatter = ISO8601_YEAR_FORMAT;
             description = "W3C XML Schema year";
         } else if (temporalClass == YearMonth.class) {
-            dateTimeFormatter = ISO8601_YEARMONTH_FORMAT;
+            dateTimeFormatter = ISO8601_YEAR_MONTH_FORMAT;
+            parserDateTimeFormatter = 
PARSER_ISO8601_EXTENDED_YEAR_MONTH_FORMAT;
             description = "W3C XML Schema year-month";
         } else if (temporalClass == LocalDate.class) {
             dateTimeFormatter = ISO8601_DATE_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_FORMAT;
             description = "W3C XML Schema date";
+        } else if (temporalClass == LocalDateTime.class || temporalClass == 
OffsetDateTime.class
+                || temporalClass == ZonedDateTime.class || temporalClass == 
Instant.class) {
+            dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
+            parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_DATE_TIME_FORMAT;
+            description = "W3C XML Schema date-time";
         } else {
-            Class<? extends Temporal> normTemporalClass =
-                    
_CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-            if (normTemporalClass != temporalClass) {
-                return getXSFormatter(normTemporalClass, timeZone);
-            } else {
-                dateTimeFormatter = ISO8601_DATE_TIME_FORMAT;
-                description = "W3C XML Schema date-time";
-            }
+            throw new BugException();
         }
-        return new ISOLikeTemplateTemporalTemporalFormat(dateTimeFormatter, 
temporalClass, timeZone, description);
+        return new ISOLikeTemplateTemporalTemporalFormat(
+                dateTimeFormatter,
+                parserDateTimeFormatter,
+                null,
+                temporalClass, timeZone, description);
     }
 
 }
diff --git a/src/main/java/freemarker/core/_CoreTemporalUtils.java 
b/src/main/java/freemarker/core/_CoreTemporalUtils.java
deleted file mode 100644
index ca1032b..0000000
--- a/src/main/java/freemarker/core/_CoreTemporalUtils.java
+++ /dev/null
@@ -1,124 +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.core;
-
-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.ZonedDateTime;
-import java.time.temporal.Temporal;
-import java.util.Arrays;
-import java.util.List;
-
-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
- * access things inside this package that users shouldn't.
- */
-public class _CoreTemporalUtils {
-
-    private _CoreTemporalUtils() {
-        // No meant to be instantiated
-    }
-
-    /**
-     * {@link Temporal} subclasses directly supperted by FreeMarker.
-     */
-    public static final List<Class<? extends Temporal>> 
SUPPORTED_TEMPORAL_CLASSES = Arrays.asList(
-            Instant.class,
-            LocalDate.class,
-            LocalDateTime.class,
-            LocalTime.class,
-            OffsetDateTime.class,
-            OffsetTime.class,
-            ZonedDateTime.class,
-            Year.class,
-            YearMonth.class);
-
-    static final boolean SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL = 
SUPPORTED_TEMPORAL_CLASSES.stream()
-            .allMatch(cl -> (cl.getModifiers() & Modifier.FINAL) == 
Modifier.FINAL);
-
-    /**
-     * Ensures that {@code ==} can be used to check if the class is assignable 
to one of the {@link Temporal} subclasses
-     * that FreeMarker directly supports. At least in Java 8 they are all 
final anyway, but just in case this changes in
-     * a future Java version, use this method before using {@code ==}.
-     *
-     * @since 2.3.31
-     */
-    public static Class<? extends Temporal> 
normalizeSupportedTemporalClass(Class<? extends Temporal> temporalClass) {
-        if (SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL) {
-            return temporalClass;
-        } else {
-            if (Instant.class.isAssignableFrom(temporalClass)) {
-                return Instant.class;
-            } else if (LocalDate.class.isAssignableFrom(temporalClass)) {
-                return LocalDate.class;
-            } else if (LocalDateTime.class.isAssignableFrom(temporalClass)) {
-                return LocalDateTime.class;
-            } else if (LocalTime.class.isAssignableFrom(temporalClass)) {
-                return LocalTime.class;
-            } else if (OffsetDateTime.class.isAssignableFrom(temporalClass)) {
-                return OffsetDateTime.class;
-            } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
-                return OffsetTime.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
-                || temporalClass == LocalDateTime.class
-                || temporalClass == ZonedDateTime.class
-                || temporalClass == OffsetDateTime.class) {
-            return Configuration.DATETIME_FORMAT_KEY;
-        } else if (temporalClass == LocalDate.class) {
-            return Configuration.DATE_FORMAT_KEY;
-        } else if (temporalClass == LocalTime.class || temporalClass == 
OffsetTime.class) {
-            return Configuration.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/template/Template.java 
b/src/main/java/freemarker/template/Template.java
index 578f48d..f71afd3 100644
--- a/src/main/java/freemarker/template/Template.java
+++ b/src/main/java/freemarker/template/Template.java
@@ -668,7 +668,7 @@ public class Template extends Configurable {
     /**
      * Returns the naming convention the parser has chosen for this template. 
If it could be determined, it's
      * {@link Configuration#LEGACY_NAMING_CONVENTION} or {@link 
Configuration#CAMEL_CASE_NAMING_CONVENTION}. If it
-     * couldn't be determined (like because there no identifier that's part of 
the template language was used where
+     * couldn't be determined (like because no identifier that's part of the 
template language was used where
      * the naming convention matters), this returns whatever the default is in 
the current configuration, so it's maybe
      * {@link Configuration#AUTO_DETECT_TAG_SYNTAX}.
      * 
diff --git a/src/main/java/freemarker/template/utility/DateUtil.java 
b/src/main/java/freemarker/template/utility/DateUtil.java
index 4023e21..a3d5020 100644
--- a/src/main/java/freemarker/template/utility/DateUtil.java
+++ b/src/main/java/freemarker/template/utility/DateUtil.java
@@ -20,16 +20,6 @@
 package freemarker.template.utility;
 
 import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.time.chrono.Chronology;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeFormatterBuilder;
-import java.time.format.DecimalStyle;
-import java.time.format.SignStyle;
-import java.time.format.TextStyle;
-import java.time.temporal.ChronoField;
-import java.time.temporal.TemporalField;
-import java.time.temporal.WeekFields;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.GregorianCalendar;
@@ -38,8 +28,6 @@ import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import freemarker.core._JavaTimeBugUtils;
-
 /**
  * Date and time related utilities.
  */
@@ -686,7 +674,7 @@ public class DateUtil {
             int millisecs = groupToMillisecond(m.group(7));
             
             // As a time is just the distance from the beginning of the day,
-            // the time-zone offest should be 0 usually.
+            // the time-zone offset should be 0 usually.
             TimeZone tz = parseMatchingTimeZone(m.group(8), defaultTZ);
             
             // Continue handling the 24:00 specail case
@@ -819,320 +807,6 @@ public class DateUtil {
     }
 
     /**
-     * Creates a {@link DateTimeFormatter} from a pattern that uses the syntax 
that's used by the
-     * {@link SimpleDateFormat} constructor.
-     *
-     * @param pattern The pattern with {@link SimpleDateFormat} syntax.
-     * @param locale The locale of the output of the formatter
-     *
-     * @return
-     *
-     * @throws IllegalArgumentException If the pattern is not a valid {@link 
SimpleDateFormat} pattern (based on the
-     * syntax documented for Java 15).
-     */
-    public static DateTimeFormatter 
dateTimeFormatterFromSimpleDateFormatPattern(String pattern, Locale locale) {
-        return 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale)
-                .toFormatter(locale)
-                .withDecimalStyle(DecimalStyle.of(locale))
-                .withChronology(getChronologyForLocaleWithLegacyRules(locale));
-    }
-
-    private static DateTimeFormatterBuilder 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(
-            String pattern, Locale locale) {
-        DateTimeFormatterBuilder builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
false);
-        if (builder == null) {
-            builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
true);
-        }
-        return builder;
-    }
-
-    /**
-     * @param standaloneFormGuess Guess if we only will have one field.
-     * @return If {@code null}, then {@code standaloneFormGuess} was wrong, 
and it also mattered, so retry with the
-     *         inverse of it.
-     */
-    private static DateTimeFormatterBuilder 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(
-            String pattern, Locale locale, boolean standaloneFormGuess) {
-        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
-
-        builder.parseCaseInsensitive(); // Must be before pattern(s) appended!
-
-        int numberOfFields = 0;
-        int len = pattern.length();
-        int pos = 0;
-        int lastClosingQuotePos = Integer.MIN_VALUE;
-        boolean standaloneFormGuessWasUsed = false;
-        do {
-            char c = pos < len ? pattern.charAt(pos++) : 0;
-            if (isAsciiLetter(c)) {
-                int startPos = pos - 1;
-                while (pos < len && pattern.charAt(pos) == c) {
-                    pos++;
-                }
-                standaloneFormGuessWasUsed |= applyRepeatedLetter(
-                        c, pos - startPos, locale, pattern, 
standaloneFormGuess, builder);
-                numberOfFields++;
-            } else if (c == '\'') {
-                int literalStartPos = pos;
-                if (lastClosingQuotePos == literalStartPos - 2) {
-                    builder.appendLiteral('\'');
-                }
-                while (pos < len && pattern.charAt(pos) != '\'') {
-                    pos++;
-                }
-                if (literalStartPos == pos) {
-                    builder.appendLiteral('\'');
-                    // Doesn't set lastClosingQuotePos
-                } else {
-                    builder.appendLiteral(pattern.substring(literalStartPos, 
pos));
-                    lastClosingQuotePos = pos;
-                }
-                pos++; // Because char at pos was already processed
-            } else {
-                int literalStartPos = pos - 1;
-                while (pos < len && 
!isAsciiLetterOrApostrophe(pattern.charAt(pos))) {
-                    pos++;
-                }
-                builder.appendLiteral(pattern.substring(literalStartPos, pos));
-                // No pos++, because the char at pos is not yet processed
-            }
-        } while (pos < len);
-        if (standaloneFormGuessWasUsed && standaloneFormGuess != 
(numberOfFields == 1)) {
-            return null;
-        }
-        return builder;
-    }
-
-    private static boolean applyRepeatedLetter(
-            char c, int width, Locale locale, String pattern,
-            boolean standaloneField,
-            DateTimeFormatterBuilder builder) {
-        boolean standaloneFieldArgWasUsed = false;
-        switch (c) {
-            case 'y':
-                appendYearLike(width, ChronoField.YEAR_OF_ERA, builder);
-                break;
-            case 'Y':
-                appendYearLike(width, WeekFields.of(locale).weekBasedYear(), 
builder);
-                break;
-            case 'M':
-            case 'L':
-                if (width <= 2) {
-                    appendValueWithSafeWidth(ChronoField.MONTH_OF_YEAR, width, 
2, builder);
-                } else if (width == 3) {
-                    TextStyle textStyle;
-                    if (c == 'M') {
-                        standaloneFieldArgWasUsed = true;
-                        textStyle = standaloneField ? 
TextStyle.SHORT_STANDALONE : TextStyle.SHORT;
-                    } else {
-                        textStyle = TextStyle.SHORT_STANDALONE;
-                    }
-
-                    if (textStyle == TextStyle.SHORT_STANDALONE
-                            && 
!_JavaTimeBugUtils.hasGoodShortStandaloneMonth(locale)) {
-                        textStyle = TextStyle.SHORT;
-                    }
-
-                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
-                } else {
-                    TextStyle textStyle;
-                    if (c == 'M') {
-                        standaloneFieldArgWasUsed = true;
-                        textStyle = standaloneField ? 
TextStyle.FULL_STANDALONE : TextStyle.FULL;
-                    } else {
-                        textStyle = TextStyle.FULL_STANDALONE;
-                    }
-
-                    if (textStyle == TextStyle.FULL_STANDALONE
-                            && 
!_JavaTimeBugUtils.hasGoodFullStandaloneMonth(locale)) {
-                        textStyle = TextStyle.FULL;
-                    }
-
-                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
-                }
-                break;
-            case 'd':
-                appendValueWithSafeWidth(ChronoField.DAY_OF_MONTH, width, 2, 
builder);
-                break;
-            case 'D':
-                if (width == 1) {
-                    builder.appendValue(ChronoField.DAY_OF_YEAR);
-                } else if (width == 2) {
-                    // 2 wide if possible, but don't lose a digit over 99. 
SimpleDateFormat does this too.
-                    builder.appendValue(ChronoField.DAY_OF_YEAR, 2, 3, 
SignStyle.NOT_NEGATIVE);
-                } else {
-                    // Here width is at least 3, so we are safe.
-                    builder.appendValue(ChronoField.DAY_OF_YEAR, width);
-                }
-                break;
-            case 'h':
-                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_AMPM, 
width, 2, builder);
-                break;
-            case 'H':
-                appendValueWithSafeWidth(ChronoField.HOUR_OF_DAY, width, 2, 
builder);
-                break;
-            case 'k':
-                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_DAY, width, 
2, builder);
-                break;
-            case 'K':
-                appendValueWithSafeWidth(ChronoField.HOUR_OF_AMPM, width, 2, 
builder);
-                break;
-            case 'a':
-                // From experimentation with SimpleDataFormat it seemed that 
the number of repetitions doesn't matter.
-                builder.appendText(ChronoField.AMPM_OF_DAY, TextStyle.SHORT);
-                break;
-            case 'm':
-                appendValueWithSafeWidth(ChronoField.MINUTE_OF_HOUR, width, 2, 
builder);
-                break;
-            case 's':
-                appendValueWithSafeWidth(ChronoField.SECOND_OF_MINUTE, width, 
2, builder);
-                break;
-            case 'S':
-                // This is quite dangerous, like "s.SS" gives misleading 
output, but SimpleDateFormat does this.
-                appendValueWithSafeWidth(ChronoField.MILLI_OF_SECOND, width, 
3, builder);
-                break;
-            case 'u':
-                builder.appendValue(ChronoField.DAY_OF_WEEK, width);
-                break;
-            case 'w':
-                
appendValueWithSafeWidth(WeekFields.of(locale).weekOfWeekBasedYear(), width, 2, 
builder);
-                break;
-            case 'W':
-                appendValueWithSafeWidth(WeekFields.of(locale).weekOfMonth(), 
width, 1, builder);
-                break;
-            case 'E':
-                if (width <= 3 ) {
-                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.SHORT);
-                } else {
-                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.FULL);
-                }
-                break;
-            case 'G':
-                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that. (It's not always a perfect
-                // match though, like japanese calendar era "Reiwa" VS "R".)
-                builder.appendText(ChronoField.ERA, TextStyle.SHORT);
-                break;
-            case 'F':
-                // While SimpleDateFormat documentation says it's "day of week 
in month", the actual output is "aligned
-                // week of month" (a bug, I assume). With DateTimeFormatter 
"F" is "aligned day of week in month", but
-                // our goal here is to mimic the behaviour of SimpleDateFormat.
-                appendValueWithSafeWidth(ChronoField.ALIGNED_WEEK_OF_MONTH, 
width, 1, builder);
-                break;
-            case 'z':
-                if (width < 4) {
-                    builder.appendZoneText(TextStyle.SHORT);
-                } else {
-                    builder.appendZoneText(TextStyle.FULL);
-                }
-                break;
-            case 'Z':
-                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that.
-                builder.appendOffset("+HHMM","+0000");
-                break;
-            case 'X':
-                if (width == 1) {
-                    // We lose the minutes here, just like SimpleDateFormat 
did.
-                    builder.appendOffset("+HH", "Z");
-                } else if (width == 2) {
-                    builder.appendOffset("+HHMM", "Z");
-                } else if (width == 3) {
-                    builder.appendOffset("+HH:MM", "Z");
-                } else {
-                    throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
-                            + StringUtil.jQuote(pattern) + ": "
-                            + " \"X\" width in SimpleDateFormat patterns must 
be less than 4.");
-                }
-                break;
-            default:
-                throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
-                        + StringUtil.jQuote(pattern) + ": "
-                        + StringUtil.jQuote(c) + " is an invalid or 
unsupported SimpleDateFormat pattern letter.");
-        }
-        return standaloneFieldArgWasUsed;
-    }
-
-    private static void appendYearLike(int width, TemporalField field, 
DateTimeFormatterBuilder builder) {
-        if (width != 2) {
-            builder.appendValue(field, width, 19, SignStyle.NORMAL);
-        } else {
-            builder.appendValueReduced(field, 2, 2, 2000);
-        }
-    }
-
-    private static String repeatChar(char c, int count) {
-        char[] chars = new char[count];
-        for (int i = 0; i < count; i++) {
-            chars[i] = c;
-        }
-        return new String(chars);
-    }
-
-    /**
-     * Used for non-negative numerical fields, behaves like {@link 
SimpleDateFormat} regarding the field width.
-     *
-     * @param width The width specified in the pattern
-     * @param safeWidth The minimum width needed to safely display any valid 
value
-     */
-    private static void appendValueWithSafeWidth(
-            TemporalField field, int width, int safeWidth, 
DateTimeFormatterBuilder builder) {
-        builder.appendValue(field, width, width < safeWidth ? safeWidth : 
width, SignStyle.NOT_NEGATIVE);
-    }
-
-    private static boolean isAsciiLetterOrApostrophe(char c) {
-        return isAsciiLetter(c) || c == '\'';
-    }
-
-    private static boolean isAsciiLetter(char c) {
-        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
-    }
-
-    /**
-     * Gives the {@link Chronology} for a {@link Locale} that {@link 
Calendar#getInstance(Locale)} would; except, that
-     * returned a {@link Calendar} instead of a {@link Chronology}, so this is 
somewhat complicated to do.
-     */
-    private static Chronology getChronologyForLocaleWithLegacyRules(Locale 
locale) {
-        // Usually null
-        String askedCalendarType = locale.getUnicodeLocaleType("ca");
-
-        Calendar calendar = Calendar.getInstance(locale);
-
-        Locale chronologyLocale;
-        String legacyLocalizedCalendarType = calendar.getCalendarType();
-        // The pre-java.time API gives different localized defaults sometimes, 
or at least for th_TH. To be on the safe
-        // side, for the two non-gregory types that pre-java.time Java 
supported out-of-the-box, we force the calendar
-        // type in the Locale, for which later we will ask the Chronology.
-        if (("buddhist".equals(legacyLocalizedCalendarType) || 
"japanese".equals(legacyLocalizedCalendarType))
-                && !legacyLocalizedCalendarType.equals(askedCalendarType)) {
-            chronologyLocale = createLocaleWithCalendarType(
-                    locale,
-                    
legacyCalendarTypeToJavaTimeApiCompatibleName(legacyLocalizedCalendarType));
-        } else {
-            // Even if there's no difference in the default chronology of the 
locale, the calendar type names that
-            // worked with the legacy API might not be recognized by the 
java.time API.
-            String compatibleAskedCalendarType = 
legacyCalendarTypeToJavaTimeApiCompatibleName(askedCalendarType);
-            if (askedCalendarType != compatibleAskedCalendarType) { // 
deliberately doesn't use equals(...)
-                chronologyLocale = createLocaleWithCalendarType(locale, 
compatibleAskedCalendarType);
-            } else {
-                chronologyLocale = locale;
-            }
-        }
-        Chronology chronology = Chronology.ofLocale(chronologyLocale);
-        return chronology;
-    }
-
-    private static String legacyCalendarTypeToJavaTimeApiCompatibleName(String 
legacyType) {
-        // "gregory" is the Calendar.calendarType in the old API, but 
Chronology.ofLocale calls it "ISO".
-        return "gregory".equals(legacyType) ? "ISO" : legacyType;
-    }
-
-    private static Locale createLocaleWithCalendarType(Locale locale, String 
legacyApiCalendarType) {
-        return new Locale.Builder()
-                .setLocale(locale)
-                .setUnicodeLocaleKeyword("ca", legacyApiCalendarType)
-                .build();
-    }
-
-    /**
      * Used internally by {@link DateUtil}; don't use its implementations for
      * anything else.
      */
diff --git a/src/main/java/freemarker/template/utility/StringUtil.java 
b/src/main/java/freemarker/template/utility/StringUtil.java
index 3317955..0bc0a6a 100644
--- a/src/main/java/freemarker/template/utility/StringUtil.java
+++ b/src/main/java/freemarker/template/utility/StringUtil.java
@@ -2156,5 +2156,13 @@ public class StringUtil {
         }
         return sb.toString();
     }
-    
+
+    /**
+     * Tells if the char is a US-ASCII letter.
+     *
+     * @since 2.3.32
+     */
+    public static boolean isUsAsciiLetter(char c) {
+        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
+    }
 }
diff --git a/src/main/java/freemarker/template/utility/TemporalUtils.java 
b/src/main/java/freemarker/template/utility/TemporalUtils.java
new file mode 100644
index 0000000..fb82e85
--- /dev/null
+++ b/src/main/java/freemarker/template/utility/TemporalUtils.java
@@ -0,0 +1,499 @@
+/*
+ * 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.text.SimpleDateFormat;
+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.ZonedDateTime;
+import java.time.chrono.Chronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DecimalStyle;
+import java.time.format.SignStyle;
+import java.time.format.TextStyle;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalField;
+import java.time.temporal.TemporalQuery;
+import java.time.temporal.WeekFields;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import freemarker.core._JavaTimeBugUtils;
+import freemarker.template.Configuration;
+
+/**
+ * Static utilities related to {@link Temporal}-s, and other {@code java.time} 
classes.
+ *
+ * @since 2.3.32
+ */
+public final class TemporalUtils {
+    private static final Map<Class<? extends Temporal>, TemporalQuery<? 
extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
+    static {
+        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, 
LocalDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, 
TemporalUtils::offsetDateTimeFrom);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, 
ZonedDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
+    }
+
+    /**
+     * {@link Temporal} subclasses directly suppoerted by FreeMarker.
+     */
+    static final List<Class<? extends Temporal>> SUPPORTED_TEMPORAL_CLASSES = 
Arrays.asList(
+            Instant.class,
+            LocalDate.class,
+            LocalDateTime.class,
+            LocalTime.class,
+            OffsetDateTime.class,
+            OffsetTime.class,
+            ZonedDateTime.class,
+            Year.class,
+            YearMonth.class);
+
+    static final boolean SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL = 
SUPPORTED_TEMPORAL_CLASSES.stream()
+            .allMatch(cl -> (cl.getModifiers() & Modifier.FINAL) == 
Modifier.FINAL);
+
+    private TemporalUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * Creates a temporal query that can be used to create an object of the 
specified temporal class from a typical
+     * parsing result.
+     */
+    public static TemporalQuery<? extends Temporal> getTemporalQuery(Class<? 
extends Temporal> temporalClass) {
+        TemporalQuery<? extends Temporal> temporalQuery = 
TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass);
+        if (temporalQuery == null) {
+            Class<? extends Temporal> normalizedTemporalClass = 
normalizeSupportedTemporalClass(
+                    temporalClass);
+            if (temporalClass != normalizedTemporalClass) {
+                temporalQuery = 
TEMPORAL_CLASS_TO_QUERY_MAP.get(normalizedTemporalClass);
+            }
+        }
+        if (temporalQuery == null) {
+            throw new IllegalArgumentException("Unsupported temporal class: " 
+ temporalClass.getName());
+        }
+        return temporalQuery;
+    }
+
+    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor 
temporal) {
+        return ZonedDateTime.from(temporal).toOffsetDateTime();
+    }
+
+    /**
+     * Creates a {@link DateTimeFormatter} from a pattern that uses the syntax 
that's used by the
+     * {@link SimpleDateFormat} constructor.
+     *
+     * @param pattern The pattern with {@link SimpleDateFormat} syntax.
+     * @param locale The locale of the output of the formatter
+     *
+     * @return
+     *
+     * @throws IllegalArgumentException If the pattern is not a valid {@link 
SimpleDateFormat} pattern (based on the
+     * syntax documented for Java 15).
+     */
+    public static DateTimeFormatter 
dateTimeFormatterFromSimpleDateFormatPattern(String pattern, Locale locale) {
+        return 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale)
+                .toFormatter(locale)
+                .withDecimalStyle(DecimalStyle.of(locale))
+                .withChronology(getChronologyForLocaleWithLegacyRules(locale));
+    }
+
+    private static DateTimeFormatterBuilder 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale) {
+        DateTimeFormatterBuilder builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
false);
+        if (builder == null) {
+            builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
true);
+        }
+        return builder;
+    }
+
+    /**
+     * @param standaloneFormGuess Guess if we only will have one field.
+     * @return If {@code null}, then {@code standaloneFormGuess} was wrong, 
and it also mattered, so retry with the
+     *         inverse of it.
+     */
+    private static DateTimeFormatterBuilder 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale, boolean standaloneFormGuess) {
+        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
+
+        builder.parseCaseInsensitive(); // Must be before pattern(s) appended!
+
+        int numberOfFields = 0;
+        int len = pattern.length();
+        int pos = 0;
+        int lastClosingQuotePos = Integer.MIN_VALUE;
+        boolean standaloneFormGuessWasUsed = false;
+        do {
+            char c = pos < len ? pattern.charAt(pos++) : 0;
+            if (StringUtil.isUsAsciiLetter(c)) {
+                int startPos = pos - 1;
+                while (pos < len && pattern.charAt(pos) == c) {
+                    pos++;
+                }
+                standaloneFormGuessWasUsed |= applyRepeatedLetter(
+                        c, pos - startPos, locale, pattern, 
standaloneFormGuess, builder);
+                numberOfFields++;
+            } else if (c == '\'') {
+                int literalStartPos = pos;
+                if (lastClosingQuotePos == literalStartPos - 2) {
+                    builder.appendLiteral('\'');
+                }
+                while (pos < len && pattern.charAt(pos) != '\'') {
+                    pos++;
+                }
+                if (literalStartPos == pos) {
+                    builder.appendLiteral('\'');
+                    // Doesn't set lastClosingQuotePos
+                } else {
+                    builder.appendLiteral(pattern.substring(literalStartPos, 
pos));
+                    lastClosingQuotePos = pos;
+                }
+                pos++; // Because char at pos was already processed
+            } else {
+                int literalStartPos = pos - 1;
+                while (pos < len && 
!isUsAsciiLetterOrApostrophe(pattern.charAt(pos))) {
+                    pos++;
+                }
+                builder.appendLiteral(pattern.substring(literalStartPos, pos));
+                // No pos++, because the char at pos is not yet processed
+            }
+        } while (pos < len);
+        if (standaloneFormGuessWasUsed && standaloneFormGuess != 
(numberOfFields == 1)) {
+            return null;
+        }
+        return builder;
+    }
+
+    private static boolean applyRepeatedLetter(
+            char c, int width, Locale locale, String pattern,
+            boolean standaloneField,
+            DateTimeFormatterBuilder builder) {
+        boolean standaloneFieldArgWasUsed = false;
+        switch (c) {
+            case 'y':
+                appendYearLike(width, ChronoField.YEAR_OF_ERA, builder);
+                break;
+            case 'Y':
+                appendYearLike(width, WeekFields.of(locale).weekBasedYear(), 
builder);
+                break;
+            case 'M':
+            case 'L':
+                if (width <= 2) {
+                    appendValueWithSafeWidth(ChronoField.MONTH_OF_YEAR, width, 
2, builder);
+                } else if (width == 3) {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? 
TextStyle.SHORT_STANDALONE : TextStyle.SHORT;
+                    } else {
+                        textStyle = TextStyle.SHORT_STANDALONE;
+                    }
+
+                    if (textStyle == TextStyle.SHORT_STANDALONE
+                            && 
!_JavaTimeBugUtils.hasGoodShortStandaloneMonth(locale)) {
+                        textStyle = TextStyle.SHORT;
+                    }
+
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                } else {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? 
TextStyle.FULL_STANDALONE : TextStyle.FULL;
+                    } else {
+                        textStyle = TextStyle.FULL_STANDALONE;
+                    }
+
+                    if (textStyle == TextStyle.FULL_STANDALONE
+                            && 
!_JavaTimeBugUtils.hasGoodFullStandaloneMonth(locale)) {
+                        textStyle = TextStyle.FULL;
+                    }
+
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                }
+                break;
+            case 'd':
+                appendValueWithSafeWidth(ChronoField.DAY_OF_MONTH, width, 2, 
builder);
+                break;
+            case 'D':
+                if (width == 1) {
+                    builder.appendValue(ChronoField.DAY_OF_YEAR);
+                } else if (width == 2) {
+                    // 2 wide if possible, but don't lose a digit over 99. 
SimpleDateFormat does this too.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, 2, 3, 
SignStyle.NOT_NEGATIVE);
+                } else {
+                    // Here width is at least 3, so we are safe.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, width);
+                }
+                break;
+            case 'h':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_AMPM, 
width, 2, builder);
+                break;
+            case 'H':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_DAY, width, 2, 
builder);
+                break;
+            case 'k':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_DAY, width, 
2, builder);
+                break;
+            case 'K':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_AMPM, width, 2, 
builder);
+                break;
+            case 'a':
+                // From experimentation with SimpleDataFormat it seemed that 
the number of repetitions doesn't matter.
+                builder.appendText(ChronoField.AMPM_OF_DAY, TextStyle.SHORT);
+                break;
+            case 'm':
+                appendValueWithSafeWidth(ChronoField.MINUTE_OF_HOUR, width, 2, 
builder);
+                break;
+            case 's':
+                appendValueWithSafeWidth(ChronoField.SECOND_OF_MINUTE, width, 
2, builder);
+                break;
+            case 'S':
+                // This is quite dangerous, like "s.SS" gives misleading 
output, but SimpleDateFormat does this.
+                appendValueWithSafeWidth(ChronoField.MILLI_OF_SECOND, width, 
3, builder);
+                break;
+            case 'u':
+                builder.appendValue(ChronoField.DAY_OF_WEEK, width);
+                break;
+            case 'w':
+                
appendValueWithSafeWidth(WeekFields.of(locale).weekOfWeekBasedYear(), width, 2, 
builder);
+                break;
+            case 'W':
+                appendValueWithSafeWidth(WeekFields.of(locale).weekOfMonth(), 
width, 1, builder);
+                break;
+            case 'E':
+                if (width <= 3 ) {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.SHORT);
+                } else {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.FULL);
+                }
+                break;
+            case 'G':
+                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that. (It's not always a perfect
+                // match though, like japanese calendar era "Reiwa" VS "R".)
+                builder.appendText(ChronoField.ERA, TextStyle.SHORT);
+                break;
+            case 'F':
+                // While SimpleDateFormat documentation says it's "day of week 
in month", the actual output is "aligned
+                // week of month" (a bug, I assume). With DateTimeFormatter 
"F" is "aligned day of week in month", but
+                // our goal here is to mimic the behaviour of SimpleDateFormat.
+                appendValueWithSafeWidth(ChronoField.ALIGNED_WEEK_OF_MONTH, 
width, 1, builder);
+                break;
+            case 'z':
+                if (width < 4) {
+                    builder.appendZoneText(TextStyle.SHORT);
+                } else {
+                    builder.appendZoneText(TextStyle.FULL);
+                }
+                break;
+            case 'Z':
+                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that.
+                builder.appendOffset("+HHMM","+0000");
+                break;
+            case 'X':
+                if (width == 1) {
+                    // We lose the minutes here, just like SimpleDateFormat 
did.
+                    builder.appendOffset("+HH", "Z");
+                } else if (width == 2) {
+                    builder.appendOffset("+HHMM", "Z");
+                } else if (width == 3) {
+                    builder.appendOffset("+HH:MM", "Z");
+                } else {
+                    throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
+                            + StringUtil.jQuote(pattern) + ": "
+                            + " \"X\" width in SimpleDateFormat patterns must 
be less than 4.");
+                }
+                break;
+            default:
+                throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
+                        + StringUtil.jQuote(pattern) + ": "
+                        + StringUtil.jQuote(c) + " is an invalid or 
unsupported SimpleDateFormat pattern letter.");
+        }
+        return standaloneFieldArgWasUsed;
+    }
+
+    private static void appendYearLike(int width, TemporalField field, 
DateTimeFormatterBuilder builder) {
+        if (width != 2) {
+            builder.appendValue(field, width, 19, SignStyle.NORMAL);
+        } else {
+            builder.appendValueReduced(field, 2, 2, 2000);
+        }
+    }
+
+    private static String repeatChar(char c, int count) {
+        char[] chars = new char[count];
+        for (int i = 0; i < count; i++) {
+            chars[i] = c;
+        }
+        return new String(chars);
+    }
+
+    /**
+     * Used for non-negative numerical fields, behaves like {@link 
SimpleDateFormat} regarding the field width.
+     *
+     * @param width The width specified in the pattern
+     * @param safeWidth The minimum width needed to safely display any valid 
value
+     */
+    private static void appendValueWithSafeWidth(
+            TemporalField field, int width, int safeWidth, 
DateTimeFormatterBuilder builder) {
+        builder.appendValue(field, width, width < safeWidth ? safeWidth : 
width, SignStyle.NOT_NEGATIVE);
+    }
+
+    private static boolean isUsAsciiLetterOrApostrophe(char c) {
+        return StringUtil.isUsAsciiLetter(c) || c == '\'';
+    }
+
+    /**
+     * Gives the {@link Chronology} for a {@link Locale} that {@link 
Calendar#getInstance(Locale)} would; except, that
+     * returned a {@link Calendar} instead of a {@link Chronology}, so this is 
somewhat complicated to do.
+     */
+    private static Chronology getChronologyForLocaleWithLegacyRules(Locale 
locale) {
+        // Usually null
+        String askedCalendarType = locale.getUnicodeLocaleType("ca");
+
+        Calendar calendar = Calendar.getInstance(locale);
+
+        Locale chronologyLocale;
+        String legacyLocalizedCalendarType = calendar.getCalendarType();
+        // The pre-java.time API gives different localized defaults sometimes, 
or at least for th_TH. To be on the safe
+        // side, for the two non-gregory types that pre-java.time Java 
supported out-of-the-box, we force the calendar
+        // type in the Locale, for which later we will ask the Chronology.
+        if (("buddhist".equals(legacyLocalizedCalendarType) || 
"japanese".equals(legacyLocalizedCalendarType))
+                && !legacyLocalizedCalendarType.equals(askedCalendarType)) {
+            chronologyLocale = createLocaleWithCalendarType(
+                    locale,
+                    
legacyCalendarTypeToJavaTimeApiCompatibleName(legacyLocalizedCalendarType));
+        } else {
+            // Even if there's no difference in the default chronology of the 
locale, the calendar type names that
+            // worked with the legacy API might not be recognized by the 
java.time API.
+            String compatibleAskedCalendarType = 
legacyCalendarTypeToJavaTimeApiCompatibleName(askedCalendarType);
+            if (askedCalendarType != compatibleAskedCalendarType) { // 
deliberately doesn't use equals(...)
+                chronologyLocale = createLocaleWithCalendarType(locale, 
compatibleAskedCalendarType);
+            } else {
+                chronologyLocale = locale;
+            }
+        }
+        Chronology chronology = Chronology.ofLocale(chronologyLocale);
+        return chronology;
+    }
+
+    private static String legacyCalendarTypeToJavaTimeApiCompatibleName(String 
legacyType) {
+        // "gregory" is the Calendar.calendarType in the old API, but 
Chronology.ofLocale calls it "ISO".
+        return "gregory".equals(legacyType) ? "ISO" : legacyType;
+    }
+
+    private static Locale createLocaleWithCalendarType(Locale locale, String 
legacyApiCalendarType) {
+        return new Locale.Builder()
+                .setLocale(locale)
+                .setUnicodeLocaleKeyword("ca", legacyApiCalendarType)
+                .build();
+    }
+
+    /**
+     * Ensures that {@code ==} can be used to check if the class is assignable 
to one of the {@link Temporal} subclasses
+     * that FreeMarker directly supports. At least in Java 8 they are all 
final anyway, but just in case this changes in
+     * a future Java version, use this method before using {@code ==}.
+     *
+     * @since 2.3.32
+     */
+    public static Class<? extends Temporal> 
normalizeSupportedTemporalClass(Class<? extends Temporal> temporalClass) {
+        if (SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL) {
+            return temporalClass;
+        } else {
+            if (Instant.class.isAssignableFrom(temporalClass)) {
+                return Instant.class;
+            } else if (LocalDate.class.isAssignableFrom(temporalClass)) {
+                return LocalDate.class;
+            } else if (LocalDateTime.class.isAssignableFrom(temporalClass)) {
+                return LocalDateTime.class;
+            } else if (LocalTime.class.isAssignableFrom(temporalClass)) {
+                return LocalTime.class;
+            } else if (OffsetDateTime.class.isAssignableFrom(temporalClass)) {
+                return OffsetDateTime.class;
+            } else if (OffsetTime.class.isAssignableFrom(temporalClass)) {
+                return OffsetTime.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;
+            }
+        }
+    }
+
+    /**
+     * Returns the FreeMarker configuration format setting name for a temporal 
class.
+     *
+     * @throws IllegalArgumentException If {@link temporalClass} is not a 
supported {@link Temporal} subclass.
+     */
+    public static String temporalClassToFormatSettingName(Class<? extends 
Temporal> temporalClass, boolean camelCase) {
+        temporalClass = normalizeSupportedTemporalClass(temporalClass);
+        if (temporalClass == Instant.class
+                || temporalClass == LocalDateTime.class
+                || temporalClass == ZonedDateTime.class
+                || temporalClass == OffsetDateTime.class) {
+            return camelCase
+                    ? Configuration.DATETIME_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.DATETIME_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == LocalDate.class) {
+            return camelCase
+                    ? Configuration.DATE_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.DATE_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == LocalTime.class || temporalClass == 
OffsetTime.class) {
+            return camelCase
+                    ? Configuration.TIME_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.TIME_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == YearMonth.class) {
+            return camelCase
+                    ? Configuration.YEAR_MONTH_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.YEAR_MONTH_FORMAT_KEY_SNAKE_CASE;
+        } else if (temporalClass == Year.class) {
+            return camelCase
+                    ? Configuration.YEAR_FORMAT_KEY_CAMEL_CASE
+                    : Configuration.YEAR_FORMAT_KEY_SNAKE_CASE;
+        } else {
+            throw new IllegalArgumentException("Unsupported temporal class: " 
+ temporalClass.getName());
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/AbstractTemporalFormatTest.java 
b/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
new file mode 100644
index 0000000..5f30bb3
--- /dev/null
+++ b/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.template.utility.StringUtil.*;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+import freemarker.template.Configuration;
+import freemarker.template.SimpleTemporal;
+import freemarker.template.Template;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.ClassUtil;
+import freemarker.template.utility.DateUtil;
+
+/**
+ * For {@link Environment}-level tests related to {@link 
TemplateTemporalFormat}-s.
+ */
+public abstract class AbstractTemporalFormatTest {
+
+    static protected String formatTemporal(Consumer<Configurable> configurer, 
Temporal... values) throws
+            TemplateException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", 
conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        StringBuilder sb = new StringBuilder();
+        for (Temporal value : values) {
+            if (sb.length() != 0) {
+                sb.append(", ");
+            }
+            sb.append(env.formatTemporalToPlainText(new SimpleTemporal(value), 
null, false));
+        }
+
+        return sb.toString();
+    }
+
+    static protected void assertParsingResults(
+            Consumer<Configurable> configurer,
+            Object... stringsAndExpectedResults) throws TemplateException, 
TemplateValueFormatException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+        conf.setTimeZone(DateUtil.UTC);
+        conf.setLocale(Locale.US);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", 
conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        if (stringsAndExpectedResults.length % 2 != 0) {
+            throw new IllegalArgumentException(
+                    "stringsAndExpectedResults.length must be even, but was " 
+ stringsAndExpectedResults.length + ".");
+        }
+        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
+            Object value = stringsAndExpectedResults[i];
+            if (!(value instanceof String)) {
+                throw new 
IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a 
String");
+            }
+            String string = (String) value;
+
+            value = stringsAndExpectedResults[i + 1];
+            if (!(value instanceof Temporal)) {
+                throw new 
IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be 
a Temporal");
+            }
+            Temporal expectedResult = (Temporal) value;
+
+            Class<? extends Temporal> temporalClass = 
expectedResult.getClass();
+            TemplateTemporalFormat templateTemporalFormat = 
env.getTemplateTemporalFormat(temporalClass);
+
+            Temporal actualResult;
+            {
+                Object actualResultObject = 
templateTemporalFormat.parse(string);
+                if (actualResultObject instanceof Temporal) {
+                    actualResult = (Temporal) actualResultObject;
+                } else if (actualResultObject instanceof 
TemplateTemporalModel) {
+                    actualResult = ((TemplateTemporalModel) 
actualResultObject).getAsTemporal();
+                } else {
+                    throw new AssertionError(
+                            "Parsing result of " + jQuote(string) + " is not 
of an expected type: "
+                                    + 
ClassUtil.getShortClassNameOfObject(actualResultObject));
+                }
+            }
+
+            if (!expectedResult.equals(actualResult)) {
+                throw new AssertionError(
+                        "Parsing result of " + jQuote(string) + " "
+                                + "(with temporalFormat[" + 
temporalClass.getSimpleName() + "]="
+                                + jQuote(env.getTemporalFormat(temporalClass)) 
+ ", "
+                                + "timeZone=" + 
jQuote(env.getTimeZone().toZoneId()) + ", "
+                                + "locale=" + jQuote(env.getLocale()) + ") "
+                                + "differs from expected.\n"
+                                + "Expected: " + expectedResult + "\n"
+                                + "Actual:   " + actualResult);
+            }
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/TemporalFormatTest2.java 
b/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
similarity index 93%
rename from src/test/java/freemarker/core/TemporalFormatTest2.java
rename to src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
index 1329579..92b2a4a 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest2.java
+++ b/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
@@ -33,9 +33,9 @@ import freemarker.template.Configuration;
 import freemarker.test.TemplateTest;
 
 /**
- * Like {@link TemporalFormatTest}, but this one contains the tests that 
utilize {@link TemplateTest}.
+ * Like {@link TemporalFormatWithJavaFormatTest}, but this one contains the 
tests that utilize {@link TemplateTest}.
  */
-public class TemporalFormatTest2 extends TemplateTest {
+public class TemporalFormatWithCustomFormatTest extends TemplateTest {
 
     @Before
     public void setup() {
diff --git a/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java 
b/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
new file mode 100644
index 0000000..f096970
--- /dev/null
+++ b/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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 org.hamcrest.Matchers.*;
+import static org.junit.Assert.*;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatterBuilder;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+import org.junit.Test;
+
+import freemarker.template.TemplateException;
+
+public class TemporalFormatWithIsoFormatTest extends 
AbstractTemporalFormatTest {
+
+    private static final Consumer<Configurable> ISO_DATE_TIME_CONFIGURER = 
conf -> conf.setDateTimeFormat("iso");
+    private static final Consumer<Configurable> ISO_DATE_CONFIGURER = conf -> 
conf.setDateFormat("iso");
+    private static final Consumer<Configurable> ISO_TIME_CONFIGURER = conf -> 
conf.setTimeFormat("iso");
+
+    @Test
+    public void testFormatOffsetTime() throws TemplateException, IOException {
+        assertEquals(
+                "13:01:02Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 1, 2), 
ZoneOffset.UTC)));
+        assertEquals(
+                "13:01:02+01:00",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 1, 2), 
ZoneOffset.ofHours(1))));
+        assertEquals(
+                "13:00:00-02:30",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0), 
ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "13:00:00.0123Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0, 12_300_000), 
ZoneOffset.UTC)));
+        assertEquals(
+                "13:00:00.3Z",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        OffsetTime.of(LocalTime.of(13, 0, 0, 300_000_000), 
ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatLocalTime() throws TemplateException, IOException {
+        assertEquals(
+                "13:01:02",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 1, 2)));
+        assertEquals(
+                "13:00:00.0123",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 0, 0, 12_300_000)));
+        assertEquals(
+                "13:00:00.3",
+                formatTemporal(
+                        ISO_TIME_CONFIGURER,
+                        LocalTime.of(13, 0, 0, 300_000_000)));
+    }
+
+    @Test
+    public void testFormatLocalDateTime() throws TemplateException, 
IOException {
+        assertEquals(
+                "2021-12-11T13:01:02",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 0)));
+        assertEquals(
+                "2021-12-11T13:01:02.0123",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000)));
+        assertEquals(
+                "2021-12-11T13:01:02.3",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 
300_000_000)));
+    }
+
+    @Test
+    public void testFormatOffsetDateTime() throws TemplateException, 
IOException {
+        assertEquals(
+                "2021-12-11T13:01:02Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, 
ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02+01:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, 
ZoneOffset.ofHours(1))));
+        assertEquals(
+                "2021-12-11T13:01:02-02:30",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, 
ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "2021-12-11T13:01:02.0123Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, 
ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02.3Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 300_000_000, 
ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatZonedDateTime() throws TemplateException, 
IOException {
+        assertEquals(
+                "2021-12-11T13:01:02Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 12, 11, 13, 1, 2, 0, 
ZoneOffset.UTC)));
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        assertEquals(
+                "2021-12-11T13:01:02-05:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 12, 11, 13, 1, 2, 0, zoneId)));
+        assertEquals(
+                "2021-07-11T13:01:02-04:00",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        ZonedDateTime.of(2021, 7, 11, 13, 1, 2, 0, zoneId)));
+        assertEquals(
+                "2021-12-11T13:01:02-02:30",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 0, 
ZoneOffset.ofHoursMinutesSeconds(-2, -30, 0))));
+        assertEquals(
+                "2021-12-11T13:01:02.0123Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, 
ZoneOffset.UTC)));
+        assertEquals(
+                "2021-12-11T13:01:02.3Z",
+                formatTemporal(
+                        ISO_DATE_TIME_CONFIGURER,
+                        OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 300_000_000, 
ZoneOffset.UTC)));
+    }
+
+    @Test
+    public void testFormatLocalDate() throws TemplateException, IOException {
+        assertEquals(
+                "2021-12-11",
+                formatTemporal(
+                        ISO_DATE_CONFIGURER,
+                        LocalDate.of(2021, 12, 11)));
+    }
+
+    @Test
+    public void testParseOffsetDateTime() throws TemplateException, 
TemplateValueFormatException {
+        // ISO extended and ISO basic format:
+        for (String s : new String[]{"2021-12-11T13:01:02.0123Z", 
"20211211T130102.0123Z"}) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    s,
+                    OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, 
ZoneOffset.UTC)) ;
+        }
+
+        // Optional parts:
+        for (String s : new String[] {
+                "2021-12-11T13:00:00.0+02:00",
+                "2021-12-11T13:00:00+02:00",
+                "2021-12-11T13:00+02",
+                "2021-12-11T13+02",
+                "20211211T130000.0+0200",
+                "20211211T130000+0200",
+                "20211211T1300+02",
+                "20211211T13+02",
+        }) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    s,
+                    OffsetDateTime.of(2021, 12, 11, 13, 0, 0, 0, 
ZoneOffset.ofHours(2)));
+        }
+
+        // TODO Zone default
+
+        try {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURER,
+                    "2021-12-11",
+                    OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, 
ZoneOffset.UTC));
+            fail("OffsetDateTime parsing should have failed");
+        } catch (UnparsableValueException e) {
+            assertThat(e.getMessage(), allOf(
+                    containsString("\"2021-12-11\""),
+                    containsString("OffsetDateTime"),
+                    containsString("\"T\"")
+            ));
+        }
+    }
+
+    @Test
+    public void testParseZonedDateTime() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalDateTime() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseInstance() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalDate() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseOffsetTime() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testParseLocalTime() throws TemplateException, 
TemplateValueFormatException {
+        // TODO [FREEMARKER-35]
+    }
+
+    @Test
+    public void testHistoricalDates() throws TemplateException, 
TemplateValueFormatException {
+        for (boolean iso8601NegativeYear : new boolean[] {false, true}) {
+            LocalDate localDate = iso8601NegativeYear
+                    ? LocalDate.of(-100, 12, 11)
+                    : LocalDate.of(0, 12, 11);
+            String iso8601String = iso8601NegativeYear
+                    ? "-0100-12-11"
+                    : "0000-12-11";
+            // Just to show that ISO 8601 year 0 is 1 BC:
+            {
+                String stringWithYearOfEra = iso8601NegativeYear
+                        ? "101-12-11 BC"
+                        : "1-12-11 BC";
+                assertEquals(
+                        localDate,
+                        new DateTimeFormatterBuilder()
+                                .appendPattern("y-MM-dd G")
+                                .toFormatter(Locale.ROOT)
+                                .withZone(ZoneOffset.UTC)
+                                .parse(stringWithYearOfEra, LocalDate::from));
+            }
+
+            String output = formatTemporal(ISO_DATE_CONFIGURER, localDate);
+            assertEquals(iso8601String, output);
+            assertParsingResults(ISO_DATE_CONFIGURER, iso8601String, 
localDate);
+        }
+    }
+
+    @Test
+    public void testParseLocaleHasNoEffect() throws TemplateException, 
TemplateValueFormatException {
+        for (Locale locale : new Locale[] {
+                Locale.CHINA,
+                Locale.FRANCE,
+                new Locale("hi", "IN"),
+                new Locale.Builder()
+                        .setLocale(Locale.JAPAN)
+                        .setUnicodeLocaleKeyword("ca", "japanese")
+                        .build()}) {
+            LocalDateTime localDateTime = LocalDateTime.of(2021, 12, 11, 13, 
1, 2, 12_300_000);
+            Consumer<Configurable> configurer = cfg -> {
+                cfg.setDateTimeFormat("iso");
+                cfg.setLocale(locale);
+            };
+            String output = formatTemporal(configurer, localDateTime);
+            String string = "2021-12-11T13:01:02.0123";
+            assertEquals(string, output);
+            assertParsingResults(configurer, string, localDateTime);
+        }
+    }
+
+}
diff --git a/src/test/java/freemarker/core/TemporalFormatTest.java 
b/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
similarity index 60%
rename from src/test/java/freemarker/core/TemporalFormatTest.java
rename to src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
index ab6e479..0107341 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest.java
+++ b/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
@@ -19,13 +19,11 @@
 
 package freemarker.core;
 
-import static freemarker.template.utility.StringUtil.*;
 import static freemarker.test.hamcerst.Matchers.*;
 import static org.hamcrest.CoreMatchers.*;
 import static org.junit.Assert.*;
 
 import java.io.IOException;
-import java.io.UncheckedIOException;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -36,26 +34,20 @@ import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
-import java.time.temporal.Temporal;
 import java.util.Locale;
 import java.util.TimeZone;
 import java.util.function.Consumer;
 
 import org.junit.Test;
 
-import freemarker.template.Configuration;
-import freemarker.template.SimpleTemporal;
-import freemarker.template.Template;
 import freemarker.template.TemplateException;
-import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
 import freemarker.test.hamcerst.Matchers;
 
-public class TemporalFormatTest {
+public class TemporalFormatWithJavaFormatTest extends 
AbstractTemporalFormatTest {
 
     @Test
-    public void testOffsetTimeAndZones() throws TemplateException, IOException 
{
+    public void testFormatOffsetTimeAndZones() throws TemplateException, 
IOException {
         OffsetTime offsetTime = OffsetTime.of(LocalTime.of(10, 0, 0), 
ZoneOffset.ofHours(1));
 
         TimeZone timeZone = TimeZone.getTimeZone("America/New_York");
@@ -86,7 +78,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testZoneConvertedWhenOffsetOrZoneNotShown() throws 
TemplateException, IOException {
+    public void testFormatZoneConvertedWhenOffsetOrZoneNotShown() throws 
TemplateException, IOException {
         TimeZone gbZone = TimeZone.getTimeZone("GB");
         assertTrue(gbZone.useDaylightTime());
         // Summer: GMT+1
@@ -143,7 +135,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testCanNotFormatLocalIfTimeZoneIsShown() {
+    public void testFormatCanNotFormatLocalIfTimeZoneIsShown() {
         try {
             formatTemporal(
                     conf -> {
@@ -162,7 +154,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testStylesAreNotSupportedForYear() {
+    public void testFormatStylesAreNotSupportedForYear() {
         try {
             formatTemporal(
                     conf -> {
@@ -180,7 +172,7 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testStylesAreNotSupportedForYearMonth() {
+    public void testFormatStylesAreNotSupportedForYearMonth() {
         try {
             formatTemporal(
                     conf -> {
@@ -198,56 +190,129 @@ public class TemporalFormatTest {
     }
 
     @Test
-    public void testDateTimeParsing() throws TemplateException, 
TemplateValueFormatException {
-        ZoneId zoneId = ZoneId.of("America/New_York");
-        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+    public void testParseDateTime() throws TemplateException, 
TemplateValueFormatException {
+        ZoneId cfgZoneId = ZoneId.of("America/New_York");
+        TimeZone cfgTimeZone = TimeZone.getTimeZone(cfgZoneId);
 
-        for (int i = 0; i < 2; i++) {
-            String stringToParse = i == 0 ? "2020-12-10 13:14" : "2020-07-10 
13:14";
-            LocalDateTime localDateTime = i == 0
+        for (boolean winter : new boolean[] {true, false}) {
+            String stringToParse = winter ? "2020-12-10 13:14" : "2020-07-10 
13:14";
+            LocalDateTime localDateTime = winter
                     ? LocalDateTime.of(2020, 12, 10, 13, 14)
                     : LocalDateTime.of(2020, 07, 10, 13, 14);
 
-            ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, 
zoneId);
-            assertParsingResults(
-                    conf -> {
-                        conf.setDateTimeFormat("y-MM-dd HH:mm");
-                        conf.setTimeZone(timeZone);
-                    },
-                    stringToParse, localDateTime,
-                    stringToParse, zonedDateTime.toOffsetDateTime(),
-                    stringToParse, zonedDateTime,
-                    stringToParse, zonedDateTime.toInstant());
+            {
+                ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, 
cfgZoneId);
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mm");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParse, localDateTime,
+                        stringToParse, zonedDateTime,
+                        stringToParse, zonedDateTime.toInstant(),
+                        stringToParse, zonedDateTime.toOffsetDateTime());
+            }
+
+            {
+                String stringToParseWithOffset = stringToParse + "+02";
+                OffsetDateTime offsetDateTime = 
localDateTime.atOffset(ZoneOffset.ofHours(2));
+                ZonedDateTime zonedDateTime = offsetDateTime.toZonedDateTime();
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mmX");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParseWithOffset, localDateTime,
+                        stringToParseWithOffset, zonedDateTime,
+                        stringToParseWithOffset, zonedDateTime.toInstant(),
+                        stringToParseWithOffset, offsetDateTime);
+            }
 
-            // TODO if zone is shown
+            {
+                ZoneId zoneIdToParse = ZoneId.of("Europe/Prague");
+                String stringToParseWithZone = stringToParse + " " + 
zoneIdToParse.getId();
+                ZonedDateTime zonedDateTime = 
localDateTime.atZone(zoneIdToParse);
+                assertParsingResults(
+                        conf -> {
+                            conf.setDateTimeFormat("y-MM-dd HH:mm z");
+                            conf.setTimeZone(cfgTimeZone);
+                        },
+                        stringToParseWithZone, localDateTime,
+                        stringToParseWithZone, zonedDateTime,
+                        stringToParseWithZone, zonedDateTime.toInstant(),
+                        stringToParseWithZone, 
zonedDateTime.toOffsetDateTime());
+            }
         }
     }
 
     @Test
-    public void testDateParsing() throws TemplateException, 
TemplateValueFormatException {
-        String stringToParse = "2020-11-10";
+    public void testParseWrongFormat() throws TemplateException, 
TemplateValueFormatException {
+        try {
+            assertParsingResults(
+                    conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"),
+                    "2020-12-10 01:14 PM", LocalDateTime.of(2020, 12, 10, 13, 
14));
+            fail("Parsing should have failed");
+        } catch (UnparsableValueException e) {
+            assertThat(
+                    e.getMessage(),
+                    allOf(
+                            containsString("\"2020-12-10 01:14 PM\""),
+                            containsString("\"y-MM-dd HH:mm\""),
+                            containsString("\"en_US\""),
+                            containsString("\"UTC\""),
+                            containsString("LocalDateTime")
+                    )
+            );
+        }
+    }
+
+    @Test
+    public void testParseDate() throws TemplateException, 
TemplateValueFormatException {
         LocalDate localDate = LocalDate.of(2020, 11, 10);
         assertParsingResults(
                 conf -> conf.setDateFormat("y-MM-dd"),
-                stringToParse, localDate);
+                "2020-11-10", localDate);
+        assertParsingResults(
+                conf -> conf.setDateFormat("yy-MM-dd"),
+                "20-11-10", localDate);
     }
 
     @Test
-    public void testLocalTimeParsing() throws TemplateException, 
TemplateValueFormatException {
+    public void testParseLocalTime() throws TemplateException, 
TemplateValueFormatException {
         String stringToParse = "13:14";
+
         assertParsingResults(
                 conf -> conf.setTimeFormat("HH:mm"),
                 stringToParse, LocalTime.of(13, 14));
-        // TODO if zone is shown
+
+        assertParsingResults(
+                conf -> {
+                    conf.setTimeFormat("HH:mmX");
+                    conf.setTimeZone(TimeZone.getTimeZone("GMT+02"));
+                },
+                stringToParse + "+02", LocalTime.of(13, 14));
     }
 
     @Test
-    public void testParsingLocalization() throws TemplateException, 
TemplateValueFormatException {
-        // TODO
+    public void testParseLocalization() throws TemplateException, 
TemplateValueFormatException {
+        LocalDate localDate = LocalDate.of(2020, 11, 10);
+        for (Locale locale : new Locale[] {
+                Locale.CHINA,
+                Locale.GERMANY,
+                new Locale("th", "TH"), // Because of the Buddhist calendar
+                Locale.US
+        }) {
+            Consumer<Configurable> configurer = conf -> {
+                conf.setDateFormat("y MMM dd");
+                conf.setLocale(locale);
+            };
+            String formattedDate = formatTemporal(configurer, localDate);
+            assertParsingResults(configurer, formattedDate, localDate);
+        }
     }
 
     @Test
-    public void testOffsetTimeParsing() throws TemplateException, 
TemplateValueFormatException {
+    public void testParseOffsetTime() throws TemplateException, 
TemplateValueFormatException {
         ZoneId zoneId = ZoneId.of("America/New_York");
         TimeZone timeZone = TimeZone.getTimeZone(zoneId);
 
@@ -273,92 +338,4 @@ public class TemporalFormatTest {
         }
     }
 
-    static private String formatTemporal(Consumer<Configurable> configurer, 
Temporal... values) throws
-            TemplateException {
-        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
-
-        configurer.accept(conf);
-
-        Environment env = null;
-        try {
-            env = new Template(null, "", 
conf).createProcessingEnvironment(null, null);
-        } catch (IOException e) {
-            throw new UncheckedIOException(e);
-        }
-
-        StringBuilder sb = new StringBuilder();
-        for (Temporal value : values) {
-            if (sb.length() != 0) {
-                sb.append(", ");
-            }
-            sb.append(env.formatTemporalToPlainText(new SimpleTemporal(value), 
null, false));
-        }
-
-        return sb.toString();
-    }
-
-    static private void assertParsingResults(
-            Consumer<Configurable> configurer,
-            Object... stringsAndExpectedResults) throws TemplateException, 
TemplateValueFormatException {
-        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
-        conf.setTimeZone(DateUtil.UTC);
-        conf.setLocale(Locale.US);
-
-        configurer.accept(conf);
-
-        Environment env = null;
-        try {
-            env = new Template(null, "", 
conf).createProcessingEnvironment(null, null);
-        } catch (IOException e) {
-            throw new UncheckedIOException(e);
-        }
-
-        if (stringsAndExpectedResults.length % 2 != 0) {
-            throw new IllegalArgumentException(
-                    "stringsAndExpectedResults.length must be even, but was " 
+ stringsAndExpectedResults.length + ".");
-        }
-        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
-            Object value = stringsAndExpectedResults[i];
-            if (!(value instanceof String)) {
-                throw new 
IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a 
String");
-            }
-            String string = (String) value;
-
-            value = stringsAndExpectedResults[i + 1];
-            if (!(value instanceof Temporal)) {
-                throw new 
IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be 
a Temporal");
-            }
-            Temporal expectedResult = (Temporal) value;
-
-            Class<? extends Temporal> temporalClass = 
expectedResult.getClass();
-            TemplateTemporalFormat templateTemporalFormat = 
env.getTemplateTemporalFormat(temporalClass);
-
-            Temporal actualResult;
-            {
-                Object actualResultObject = 
templateTemporalFormat.parse(string);
-                if (actualResultObject instanceof Temporal) {
-                    actualResult = (Temporal) actualResultObject;
-                } else if (actualResultObject instanceof 
TemplateTemporalModel) {
-                    actualResult = ((TemplateTemporalModel) 
actualResultObject).getAsTemporal();
-                } else {
-                    throw new AssertionError(
-                            "Parsing result of " + jQuote(string) + " is not 
of an expected type: "
-                                    + 
ClassUtil.getShortClassNameOfObject(actualResultObject));
-                }
-            }
-
-            if (!expectedResult.equals(actualResult)) {
-                throw new AssertionError(
-                        "Parsing result of " + jQuote(string) + " "
-                                + "(with temporalFormat[" + 
temporalClass.getSimpleName() + "]="
-                                + jQuote(env.getTemporalFormat(temporalClass)) 
+ ", "
-                                + "timeZone=" + 
jQuote(env.getTimeZone().toZoneId()) + ", "
-                                + "locale=" + jQuote(env.getLocale()) + ") "
-                                + "differs from expected.\n"
-                                + "Expected: " + expectedResult + "\n"
-                                + "Actual:   " + actualResult);
-            }
-        }
-    }
-
 }
diff --git 
a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java 
b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
index 1f168e1..c11b6ef 100644
--- a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
+++ b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
@@ -90,23 +90,27 @@ public class DateUtilsPatternParsingTest {
 
     @Test
     public void testAllLettersAndWidths() {
-        for (String letter : new String[] {
-                "G", "y", "Y", "M", "L", "w", "W", "D", "d", "F", "E", "u", 
"a", "H", "k", "K", "h", "m", "s", "S",
-                "z", "Z", "X"}) {
-            for (int width = 1; width <= 6; width++) {
-                if (letter.equals("X") && width > 3) {
-                    // Not supported by SimpleDateFormat.
-                    continue;
-                }
-                String pattern = StringUtils.repeat(letter, width);
-                for (ZonedDateTime zdt : SAMPLE_ZDTS) {
-                    for (Locale locale : SAMPLE_LOCALES) {
-                        if (letter.equals("G") && _JavaVersion.FEATURE > 8 && 
!locale.equals(Locale.US)) {
-                            // SDF and DTF formats Era differently for many 
locales after Java 8. US locale remains
-                            // consistent as of Java 13, so let's hope it 
won't break, and so we can have some coverage.
-                            continue;
+        // Prefix is used to have both standalone and non-standalone 
formatting of the repeated letter.
+        for (String prefix : new String[] {"", "y "}) {
+            for (String letter : new String[]{
+                    "G", "y", "Y", "M", "L", "w", "W", "D", "d", "F", "E", 
"u", "a", "H", "k", "K", "h", "m", "s", "S",
+                    "z", "Z", "X"}) {
+                for (int width = 1; width <= 6; width++) {
+                    if (letter.equals("X") && width > 3) {
+                        // Not supported by SimpleDateFormat.
+                        continue;
+                    }
+                    String pattern = prefix + StringUtils.repeat(letter, 
width);
+                    for (ZonedDateTime zdt : SAMPLE_ZDTS) {
+                        for (Locale locale : SAMPLE_LOCALES) {
+                            if (letter.equals("G") && _JavaVersion.FEATURE > 8 
&& !locale.equals(Locale.US)) {
+                                // SDF and DTF formats Era differently for 
many locales after Java 8. US locale remains
+                                // consistent as of Java 13, so let's hope it 
won't break, and so we can have some
+                                // coverage.
+                                continue;
+                            }
+                            assertSDFAndDTFOutputsEqual(pattern, zdt, locale);
                         }
-                        assertSDFAndDTFOutputsEqual(pattern, zdt, locale);
                     }
                 }
             }
@@ -136,17 +140,6 @@ public class DateUtilsPatternParsingTest {
         }
     }
 
-
-    @Test
-    public void testStandaloneOrNot() {
-        for (Locale locale : SAMPLE_LOCALES) {
-            assertSDFAndDTFOutputsEqual("MMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("y MMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("MMMM", SAMPLE_ZDT, locale);
-            assertSDFAndDTFOutputsEqual("y MMMM", SAMPLE_ZDT, locale);
-        }
-    }
-
     @Test
     public void testCalendars() {
         Locale baseLocale = new Locale("th", "TH");
@@ -183,14 +176,14 @@ public class DateUtilsPatternParsingTest {
     @Test
     public void testInvalidPatternExceptions() {
         try {
-            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("y v", 
SAMPLE_LOCALE);
+            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("y v", 
SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("\"v\""));
         }
 
         try {
-            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", 
SAMPLE_LOCALE);
+            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", 
SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("4"));
@@ -202,7 +195,7 @@ public class DateUtilsPatternParsingTest {
         assertEquals(
                 LocalDateTime.of(2021, 12, 23, 1, 2, 3),
                 LocalDateTime.from(
-                        
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", 
SAMPLE_LOCALE)
+                        
TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", 
SAMPLE_LOCALE)
                                 .parse("20211223010203")));
     }
 
@@ -270,7 +263,7 @@ public class DateUtilsPatternParsingTest {
         SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale);
         sdf.setTimeZone(timeZone);
 
-        DateTimeFormatter dtf = 
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
+        DateTimeFormatter dtf = 
TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
 
         String sdfOutput = sdf.format(date);
         String dtfOutput = dtf.format(temporal);
@@ -309,7 +302,7 @@ public class DateUtilsPatternParsingTest {
 
     private LocalDate parseLocalDate(String pattern, String string, Locale 
locale) {
         return LocalDate.from(
-                DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, 
locale)
+                
TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale)
                         .parse(string));
     }
 
diff --git a/src/test/java/freemarker/core/CoreTemporalUtilTest.java 
b/src/test/java/freemarker/template/utility/TemporalUtilsTest.java
similarity index 66%
rename from src/test/java/freemarker/core/CoreTemporalUtilTest.java
rename to src/test/java/freemarker/template/utility/TemporalUtilsTest.java
index 36fd589..2b5c06f 100644
--- a/src/test/java/freemarker/core/CoreTemporalUtilTest.java
+++ b/src/test/java/freemarker/template/utility/TemporalUtilsTest.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-package freemarker.core;
+package freemarker.template.utility;
 
 import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
@@ -31,21 +31,21 @@ import org.junit.Test;
 
 import freemarker.template.Configuration;
 
-public class CoreTemporalUtilTest {
+public class TemporalUtilsTest {
 
     @Test
     public void testSupportedTemporalClassAreFinal() {
         assertTrue(
                 "FreeMarker was implemented with the assumption that temporal 
classes are final. While there "
-                        + "are mesures in palce to handle if it's not a case, 
it would be better to review the code.",
-                _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL);
+                        + "are measures in place to handle if it's not a case, 
it would be better to review the code.",
+                TemporalUtils.SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL);
     }
 
     @Test
     public void testGetTemporalFormat() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
 
-        for (Class<? extends Temporal> supportedTemporalClass : 
_CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+        for (Class<? extends Temporal> supportedTemporalClass : 
TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
             assertNotNull(cfg.getTemporalFormat(supportedTemporalClass));
         }
 
@@ -61,15 +61,18 @@ public class CoreTemporalUtilTest {
     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) {
-            
uniqueSettingNames.add(_CoreTemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass));
+        for (boolean camelCase : new boolean[] {false, true}) {
+            Set<String> uniqueSettingNames = new HashSet<>();
+            for (Class<? extends Temporal> supportedTemporalClass : 
TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+                uniqueSettingNames.add(
+                        
TemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass, 
camelCase));
+            }
+            assertThat(uniqueSettingNames.size(), 
equalTo(TemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
+            assertTrue(uniqueSettingNames.stream().allMatch(it -> 
cfg.getSettingNames(camelCase).contains(it)));
         }
-        assertThat(uniqueSettingNames.size(), 
equalTo(_CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
-        assertTrue(uniqueSettingNames.stream().allMatch(it -> 
cfg.getSettingNames(false).contains(it)));
 
         try {
-            
_CoreTemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class);
+            
TemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class, false);
             fail();
         } catch (IllegalArgumentException e) {
             // Expected

Reply via email to