You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2022/03/05 22:38:57 UTC

[freemarker] branch FREEMARKER-35 updated: [FREEMARKER-35] Added MissingTimeZoneParserPolicy to parse method, and implemented it for JavaTemplateTemporalFormat and ISOLikeTemplateTemporalTemporalFormat. Added more parsing tests. Changed TemporalUtils to internal class (now called _TemporalUtils).

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


The following commit(s) were added to refs/heads/FREEMARKER-35 by this push:
     new 192364c  [FREEMARKER-35] Added MissingTimeZoneParserPolicy to parse method, and implemented it for JavaTemplateTemporalFormat and ISOLikeTemplateTemporalTemporalFormat. Added more parsing tests. Changed TemporalUtils to internal class (now called _TemporalUtils).
192364c is described below

commit 192364cd85548acc87bdda28a988ab530a19e8d4
Author: ddekany <dd...@apache.org>
AuthorDate: Sat Mar 5 14:17:06 2022 +0100

    [FREEMARKER-35] Added MissingTimeZoneParserPolicy to parse method, and implemented it for JavaTemplateTemporalFormat and ISOLikeTemplateTemporalTemporalFormat. Added more parsing tests. Changed TemporalUtils to internal class (now called _TemporalUtils).
---
 src/main/java/freemarker/core/Configurable.java    |   3 +-
 .../DateTimeFormatBasedTemplateTemporalFormat.java | 143 +++++++
 src/main/java/freemarker/core/Environment.java     |   3 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |  99 +----
 .../core/ISOTemplateTemporalFormatFactory.java     |   4 +-
 .../core/JavaTemplateTemporalFormat.java           | 106 ++----
 .../core/MissingTimeZoneParserPolicy.java          |  54 +++
 .../freemarker/core/TemplateTemporalFormat.java    |   5 +-
 .../core/XSTemplateTemporalFormatFactory.java      |   4 +-
 src/main/java/freemarker/core/_MessageUtil.java    |   5 +
 .../_TemporalUtils.java}                           |  41 +-
 .../core/AbstractTemporalFormatTest.java           | 130 ++++++-
 ....java => CustomTemplateTemporalFormatTest.java} |   4 +-
 ...pochMillisDivTemplateTemporalFormatFactory.java |   2 +-
 .../EpochMillisTemplateTemporalFormatFactory.java  |   2 +-
 .../core/HTMLISOTemplateTemporalFormatFactory.java |   2 +-
 ...java => ISOLikeTemplateTemporalFormatTest.java} | 412 ++++++++++++++++++---
 ...st.java => JavaTemplateTemporalFormatTest.java} | 145 +++++---
 ...ndTZSensitiveTemplateTemporalFormatFactory.java |   2 +-
 .../_TemporalUtilsTest.java}                       |  29 +-
 .../utility/DateUtilsPatternParsingTest.java       |  11 +-
 21 files changed, 894 insertions(+), 312 deletions(-)

diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java
index 17fca68..525c25f 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -82,7 +82,6 @@ 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},
@@ -1458,7 +1457,7 @@ public class Configurable {
         } else {
             // Handle the unlikely situation that in some future Java version we can have subclasses.
             Class<? extends Temporal> normTemporalClass =
-                    TemporalUtils.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/DateTimeFormatBasedTemplateTemporalFormat.java b/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java
new file mode 100644
index 0000000..eb8ee95
--- /dev/null
+++ b/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import static freemarker.core._TemporalUtils.*;
+import static freemarker.template.utility.StringUtil.*;
+
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAccessor;
+
+/**
+ * Was created ad-hoc to contain whatever happens to be common between some of our {@link TemplateTemporalFormat}-s.
+ */
+abstract class DateTimeFormatBasedTemplateTemporalFormat extends TemplateTemporalFormat {
+    protected final Class<? extends Temporal> temporalClass;
+    protected final boolean isLocalTemporalClass;
+    protected final ZoneId zoneId;
+
+    public DateTimeFormatBasedTemplateTemporalFormat(
+            Class<? extends Temporal> temporalClass, ZoneId zoneId) {
+        temporalClass = _TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        this.temporalClass = temporalClass;
+        this.isLocalTemporalClass = isLocalTemporalClass(temporalClass);
+        this.zoneId = zoneId;
+    }
+
+    protected Temporal parse(
+            String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy,
+            DateTimeFormatter parserDateTimeFormatter) throws UnparsableValueException {
+        try {
+            TemporalAccessor parseResult = parserDateTimeFormatter.parse(s);
+
+            if (isLocalTemporalClass
+                    || parseResult.isSupported(ChronoField.OFFSET_SECONDS)
+                    || parseResult.isSupported(ChronoField.INSTANT_SECONDS)) {
+                return parseResult.query(_TemporalUtils.getTemporalQuery(temporalClass));
+            }
+
+            switch (missingTimeZoneParserPolicy) {
+                case ASSUME_CURRENT_TIME_ZONE:
+                case FALL_BACK_TO_LOCAL_TEMPORAL:
+                    boolean fallbackToLocal = missingTimeZoneParserPolicy == MissingTimeZoneParserPolicy.FALL_BACK_TO_LOCAL_TEMPORAL;
+                    Class<? extends Temporal> localFallbackTemporalClass;
+                    if (temporalClass == Instant.class) {
+                        localFallbackTemporalClass = LocalDateTime.class;
+                    } else {
+                        localFallbackTemporalClass = getLocalTemporalClassForNonLocal(temporalClass);
+                        if (localFallbackTemporalClass == null) {
+                            throw newUnparsableValueException(
+                                    s, parserDateTimeFormatter,
+                                    "String contains no zone offset, and no local temporal type "
+                                            + "exists for target type " + temporalClass.getName(),
+                                    null);
+                        }
+                        if (!fallbackToLocal && temporalClass == OffsetTime.class) {
+                            throw newUnparsableValueException(
+                                    s, parserDateTimeFormatter,
+                                    "It's not possible to parse the string that contains no zone offset to OffsetTime, "
+                                            + "because we don't know the day, and hence can't account for "
+                                            + "Daylight Saving Time, and thus we can't apply the current time zone."
+                                            + temporalClass.getName(),
+                                    null);
+                        }
+                    }
+
+                    Temporal resultTemporal = parseResult.query(
+                            _TemporalUtils.getTemporalQuery(localFallbackTemporalClass));
+                    if (fallbackToLocal) {
+                        return resultTemporal;
+                    }
+                    ZonedDateTime zonedDateTime = ((LocalDateTime) resultTemporal).atZone(zoneId);
+                    if (temporalClass == ZonedDateTime.class) {
+                        return zonedDateTime;
+                    } else if (temporalClass == OffsetDateTime.class) {
+                        return zonedDateTime.toOffsetDateTime();
+                    } else if (temporalClass == Instant.class) {
+                        return zonedDateTime.toInstant();
+                    }
+                    throw new AssertionError("Unexpected case: " + temporalClass);
+                case FAIL:
+                    throw newUnparsableValueException(
+                            s, parserDateTimeFormatter,
+                            _MessageUtil.FAIL_MISSING_TIME_ZONE_PARSER_POLICY_ERROR_DETAIL, null);
+                default:
+                    throw new AssertionError();
+            }
+        } catch (DateTimeException e) {
+            throw newUnparsableValueException(s, parserDateTimeFormatter, e.getMessage(), e);
+        }
+    }
+
+    protected UnparsableValueException newUnparsableValueException(
+            String s, DateTimeFormatter dateTimeFormatter,
+            String cause, DateTimeException e) {
+        StringBuilder message = new StringBuilder();
+
+        message.append("Failed to parse value ").append(jQuote(s))
+                .append(" with format ").append(jQuote(getDescription()))
+                .append(", and target class ").append(temporalClass.getSimpleName());
+        if (dateTimeFormatter != null) {
+            message.append(", ").append("locale ").append(jQuote(dateTimeFormatter.getLocale()));
+        }
+        if (zoneId != null) {
+            message.append(", ").append("zoneId ").append(jQuote(zoneId));
+        }
+        message.append(".");
+        if (dateTimeFormatter != null) {
+            message.append("\n(DateTimeFormatter used: ").append(jQuote(dateTimeFormatter)).append(")");
+        }
+        message.append("\nCause: ").append(cause);
+
+        return new UnparsableValueException(
+                message.toString(),
+                e);
+    }
+
+}
diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java
index 374bd98..b7153d2 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -82,7 +82,6 @@ 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;
 
 /**
@@ -2343,7 +2342,7 @@ public final class Environment extends Configurable {
             String settingName;
             String settingValue;
             try {
-                settingName = TemporalUtils.temporalClassToFormatSettingName(
+                settingName = _TemporalUtils.temporalClassToFormatSettingName(
                         temporalClass,
                         blamedTemporalSourceExp != null
                                 ? blamedTemporalSourceExp.getTemplate().getActualNamingConvention()
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 2923940..07d6b8e 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -19,8 +19,7 @@
 
 package freemarker.core;
 
-import static freemarker.template.utility.StringUtil.*;
-import static freemarker.template.utility.TemporalUtils.*;
+import static freemarker.core._TemporalUtils.*;
 
 import java.time.DateTimeException;
 import java.time.Instant;
@@ -29,19 +28,14 @@ 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.temporal.ChronoField;
 import java.time.temporal.ChronoUnit;
 import java.time.temporal.Temporal;
-import java.time.temporal.TemporalAccessor;
-import java.time.temporal.TemporalQuery;
 import java.util.TimeZone;
 import java.util.regex.Pattern;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
-import freemarker.template.utility.TemporalUtils;
 
 // TODO [FREEMARKER-35] These should support parameters similar to {@link ISOTemplateDateFormat},
 
@@ -50,31 +44,24 @@ import freemarker.template.utility.TemporalUtils;
  *
  * @since 2.3.32
  */
-final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat {
+final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTemplateTemporalFormat {
     private final DateTimeFormatter dateTimeFormatter;
     private final boolean instantConversion;
-    private final ZoneId zoneId;
     private final String description;
-    private final TemporalQuery<? extends Temporal> temporalQuery;
-    private final Class<? extends Temporal> temporalClass;
     private final DateTimeFormatter parserExtendedDateTimeFormatter;
     private final DateTimeFormatter parserBasicDateTimeFormatter;
-    private final boolean localTemporalClass;
 
     ISOLikeTemplateTemporalTemporalFormat(
             DateTimeFormatter dateTimeFormatter,
             DateTimeFormatter parserExtendedDateTimeFormatter,
             DateTimeFormatter parserBasicDateTimeFormatter,
             Class<? extends Temporal> temporalClass, TimeZone zone, String formatString) {
+        super(temporalClass, zone.toZoneId());
         temporalClass = normalizeSupportedTemporalClass(temporalClass);
         this.dateTimeFormatter = dateTimeFormatter;
         this.parserExtendedDateTimeFormatter = parserExtendedDateTimeFormatter;
         this.parserBasicDateTimeFormatter = parserBasicDateTimeFormatter;
-        this.temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
         this.instantConversion = temporalClass == Instant.class;
-        this.temporalClass = temporalClass;
-        this.localTemporalClass = isLocalTemporalClass(temporalClass);
-        this.zoneId = temporalClass == Instant.class ? zone.toZoneId() : null;
         this.description = formatString;
     }
 
@@ -97,7 +84,9 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
     }
 
     @Override
-    public Object parse(String s) throws TemplateValueFormatException {
+    public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException {
+        // TODO [FREEMARKER-35] Implement missingTimeZoneParserPolicy
+
         final boolean extendedFormat;
         final boolean add1Day;
         if (temporalClass == LocalDate.class || temporalClass == YearMonth.class) {
@@ -116,10 +105,9 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
         } 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.");
+                throw newUnparsableValueException(
+                        s, null,
+                        "Character \"T\" must be used to separate the date and time part.", null);
             }
             if (s.indexOf(":", tIndex + 1) != -1) {
                 extendedFormat = true;
@@ -138,35 +126,11 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
 
         DateTimeFormatter parserDateTimeFormatter = parserBasicDateTimeFormatter == null || extendedFormat
                 ? parserExtendedDateTimeFormatter : parserBasicDateTimeFormatter;
-        try {
-            TemporalAccessor parseResult = parserDateTimeFormatter.parse(s);
-            if (!localTemporalClass && !parseResult.isSupported(ChronoField.OFFSET_SECONDS)) {
-                // Unlike for the Java format, for ISO we require the string to contain the offset for a non-local
-                // target type. We could use the default time zone, but that's really just guessing, also DST creates
-                // ambiguous cases. For the Java formatter we are lenient, as the shared date-time format typically
-                // misses the offset, and because we don't want a format-and-then-parse cycle to fail. But in ISO
-                // format, the offset is always shown for a non-local temporal.
-                throw new UnparsableValueException(
-                        "Failed to parse value " + jQuote(s) + " with format " + jQuote(description)
-                                + ", and target class " + temporalClass.getSimpleName() + ": "
-                                + "The string must contain the time zone offset for this target class. "
-                                + "(Defaulting to the current time zone is not allowed for ISO-style formats.)");
-
-            }
-            Temporal resultTemporal = parseResult.query(temporalQuery);
-            if (add1Day) {
-                resultTemporal = resultTemporal.plus(1, ChronoUnit.DAYS);
-            }
-            return resultTemporal;
-        } catch (DateTimeException 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);
+        Temporal resultTemporal = parse(s, missingTimeZoneParserPolicy, parserDateTimeFormatter);
+        if (add1Day) {
+            resultTemporal = resultTemporal.plus(1, ChronoUnit.DAYS);
         }
+        return resultTemporal;
     }
 
     private final static Pattern ZERO_TIME_AFTER_HH = Pattern.compile("(?::?+00(?::?+00(?:.?+0+)?)?)?");
@@ -197,40 +161,6 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
         return ZERO_TIME_AFTER_HH.matcher(timeAfterHH).matches();
     }
 
-    //!!T
-    public static void main(String[] args) {
-        for (String original : new String[] {"24", "24:00", "24:00:00", "24:00:00.0"}) {
-            for (boolean basic : new boolean[] {false, true}) {
-                for (String prefix : new String[] {"", "T"}) {
-                    for (String suffix : new String[] {"", "Z", "-01", "+01"}) {
-                        String s = prefix + (basic ? original.replace(":", "") : original)+ suffix;
-
-                        int startIndex = s.indexOf("24");
-                        if (!isStartOf240000(s, startIndex)) {
-                            throw new AssertionError("Couldn't find end of time part in: " + s);
-                        }
-                    }
-                }
-            }
-        }
-
-        for (String original : new String[] {
-                "24:", "24:01", "24:00:01", "24:00:00.1", "24:0", "24:00:x",
-                "2401", "240001", "240000.1", "240"}) {
-            for (String prefix : new String[] {"", "T"}) {
-                for (String suffix : new String[] {"", "Z", "-01", "+01"}) {
-                    String s = prefix + original + suffix;
-
-                    int startIndex = s.indexOf("24");
-                    if (isStartOf240000(s, startIndex)) {
-                        throw new AssertionError("Shouldn't match: " + s);
-                    }
-                }
-            }
-        }
-
-    }
-
     @Override
     public boolean isLocaleBound() {
         return false;
@@ -238,7 +168,8 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
 
     @Override
     public boolean isTimeZoneBound() {
-        return zoneId != null;
+        // TODO [FREEMARKER-35] Even for local temporals?
+        return true;
     }
 
     @Override
diff --git a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
index 48b48a5..9a6ad0c 100644
--- a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java
@@ -40,8 +40,6 @@ 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.
  */
@@ -212,7 +210,7 @@ class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
         final DateTimeFormatter parserExtendedDateTimeFormatter;
         final DateTimeFormatter parserBasicDateTimeFormatter;
         final String description;
-        temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        temporalClass = _TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
             parserExtendedDateTimeFormatter = PARSER_ISO8601_EXTENDED_TIME_FORMAT;
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 85311f6..718f70b 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -31,10 +31,8 @@ import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
-import java.time.temporal.TemporalQuery;
 import java.util.Locale;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
@@ -43,23 +41,19 @@ import java.util.regex.Pattern;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
-import freemarker.template.utility.TemporalUtils;
 
 /**
  * See {@link JavaTemplateTemporalFormatFactory}.
  *
  * @since 2.3.32
  */
-class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
+class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalFormat {
 
     enum PreFormatValueConversion {
         INSTANT_TO_ZONED_DATE_TIME,
+        AS_LOCAL_IN_CURRENT_ZONE,
         SET_ZONE_FROM_OFFSET,
-        OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION
-    }
-
-    enum SpecialParsing {
-        OFFSET_TIME_DST_ERROR
+        OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION,
     }
 
     static final String SHORT = "short";
@@ -77,24 +71,19 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
     private final DateTimeFormatter dateTimeFormatter;
-    private final TemporalQuery<? extends Temporal> temporalQuery;
     private final ZoneId zoneId;
     private final String formatString;
-    private final Class<? extends Temporal> temporalClass;
     private final PreFormatValueConversion preFormatValueConversion;
-    private final SpecialParsing specialParsing;
 
-    JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone)
+    JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale,
+            TimeZone timeZone)
             throws InvalidFormatParametersException {
-        this.temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-
-        temporalQuery = TemporalUtils.getTemporalQuery(temporalClass);
+        super(temporalClass, timeZone.toZoneId());
 
         final Matcher formatStylePatternMatcher = FORMAT_STYLE_PATTERN.matcher(formatString);
         final boolean isFormatStyleString = formatStylePatternMatcher.matches();
         FormatStyle timePartFormatStyle; // Maybe changes later for re-attempts
         final FormatStyle datePartFormatStyle;
-        boolean formatWithZone;
 
         DateTimeFormatter dateTimeFormatter; // Maybe changes later for re-attempts
         if (isFormatStyleString) {
@@ -120,29 +109,28 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
             } else {
                 throw new InvalidFormatParametersException(
                         "Format styles (like " + jQuote(formatString) + ") is not supported for "
-                        + temporalClass.getName() + " values.");
+                                + temporalClass.getName() + " values.");
             }
         } else {
             datePartFormatStyle = null;
             timePartFormatStyle = null;
 
             try {
-                dateTimeFormatter = TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
+                dateTimeFormatter = _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
             } catch (IllegalArgumentException e) {
                 throw new InvalidFormatParametersException(e.getMessage(), e);
             }
         }
 
         // Handling of time zone related edge cases
-        if (TemporalUtils.isLocalTemporalClass(temporalClass)) {
+        if (isLocalTemporalClass) {
             this.preFormatValueConversion = null;
-            this.specialParsing = null;
-            formatWithZone = false;
             if (isFormatStyleString && (temporalClass == LocalTime.class || temporalClass == LocalDateTime.class)) {
                 // The localized pattern possibly contains the time zone (for most locales, LONG and FULL does), so they
                 // fail with local temporals that have a time part. To work this issue around, we decrease the verbosity
                 // of the time style until formatting succeeds. (See also: JDK-8085887)
-                localFormatAttempt: while (true) {
+                localFormatAttempt:
+                while (true) {
                     try {
                         dateTimeFormatter.format(LOCAL_DATE_TIME_SAMPLE); // We only care if it throws exception or not
                         break localFormatAttempt; // It worked
@@ -168,50 +156,39 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
             }
         } else { // is non-local temporal
             PreFormatValueConversion preFormatValueConversion;
-            SpecialParsing specialParsing;
-            if (showsZone(dateTimeFormatter)) {
+            if (showsOffsetOrZone(dateTimeFormatter)) {
                 if (temporalClass == Instant.class) {
                     preFormatValueConversion = PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME;
-                    specialParsing = null;
                 } else if (isFormatStyleString &&
                         (temporalClass == OffsetDateTime.class || temporalClass == OffsetTime.class)) {
                     preFormatValueConversion = PreFormatValueConversion.SET_ZONE_FROM_OFFSET;
-                    specialParsing = null;
                 } else {
                     preFormatValueConversion = null;
-                    specialParsing = null;
                 }
-                formatWithZone = false;
             } else { // Doesn't show zone
                 if (temporalClass == OffsetTime.class) {
                     // We give up, but delay the exception until the format is actually used, just in case
                     // format creation is triggered without actually using it.
-                    preFormatValueConversion = PreFormatValueConversion.OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION;
-                    specialParsing = SpecialParsing.OFFSET_TIME_DST_ERROR;
-                    formatWithZone = false;
+                    preFormatValueConversion =
+                            PreFormatValueConversion.OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION;
                 } else {
                     // As no zone is shown, but our temporal class is not local, we tell the formatter convert to
                     // the current time zone. Also, when parsing, that same time zone will be assumed.
-                    preFormatValueConversion = null;
-                    specialParsing = null;
-                    formatWithZone = true;
+                    preFormatValueConversion = PreFormatValueConversion.AS_LOCAL_IN_CURRENT_ZONE;
                 }
             }
             this.preFormatValueConversion = preFormatValueConversion;
-            this.specialParsing = specialParsing;
         }
 
         dateTimeFormatter = dateTimeFormatter.withLocale(locale);
-        if (formatWithZone) {
-            dateTimeFormatter = dateTimeFormatter.withZone(timeZone.toZoneId());
-        }
         this.dateTimeFormatter = dateTimeFormatter;
         this.formatString = formatString;
         this.zoneId = timeZone.toZoneId();
     }
 
     @Override
-    public String formatToPlainText(TemplateTemporalModel tm) throws TemplateValueFormatException, TemplateModelException {
+    public String formatToPlainText(TemplateTemporalModel tm) throws TemplateValueFormatException,
+            TemplateModelException {
         DateTimeFormatter dateTimeFormatter = this.dateTimeFormatter;
         Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
 
@@ -237,6 +214,19 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                                         + ClassUtil.getShortClassNameOfObject(temporal));
                     }
                     break;
+                case AS_LOCAL_IN_CURRENT_ZONE:
+                    // We could use dateTimeFormatter.withZone(zoneId) for these, but it's not obvious if that will
+                    // always behave as a straightforward conversion to the local temporal type.
+                    if (temporal instanceof OffsetDateTime) {
+                        temporal = ((OffsetDateTime) temporal).atZoneSameInstant(zoneId).toLocalDateTime();
+                    } else if (temporal instanceof ZonedDateTime) {
+                        temporal = ((ZonedDateTime) temporal).withZoneSameInstant(zoneId).toLocalDateTime();
+                    } else if (temporal instanceof Instant) {
+                        temporal = ((Instant) temporal).atZone(zoneId).toLocalDateTime();
+                    } else {
+                        throw new AssertionError("Unhandled case: " + temporal.getClass());
+                    }
+                    break;
                 case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION:
                     throw newOffsetTimeWithoutOffsetOnTheFormatException();
                 default:
@@ -259,28 +249,9 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     }
 
     @Override
-    public Object parse(String s) throws TemplateValueFormatException {
-        if (specialParsing != null) {
-            switch (specialParsing) {
-                case OFFSET_TIME_DST_ERROR:
-                    throw newOffsetTimeWithoutOffsetOnTheFormatException();
-                default:
-                    throw new BugException();
-            }
-        }
-
-        try {
-            return dateTimeFormatter.parse(s, temporalQuery);
-        } catch (DateTimeParseException e) {
-            throw new UnparsableValueException(
-                    "Failed to parse value " + jQuote(s) + " with format " + jQuote(formatString)
-                            + ", and target class " + temporalClass.getSimpleName() + ", "
-                            + "locale " + jQuote(dateTimeFormatter.getLocale()) + ", "
-                            + "zoneId " + jQuote(zoneId) + ".\n"
-                            + "(Used this DateTimeFormatter: " + dateTimeFormatter + ")\n"
-                            + "(Root cause message: " + e.getMessage() + ")",
-                    e);
-        }
+    public Temporal parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws
+            TemplateValueFormatException {
+        return parse(s, missingTimeZoneParserPolicy, dateTimeFormatter);
     }
 
     @Override
@@ -301,17 +272,18 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
      */
     @Override
     public boolean isTimeZoneBound() {
+        // TODO [FREEMARKER-35] Even for local temporals?
         return true;
     }
 
-    private static final ZonedDateTime SHOWS_ZONE_SAMPLE_TEMPORAL_1 = ZonedDateTime.of(
+    private static final ZonedDateTime SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_1 = ZonedDateTime.of(
             LocalDateTime.of(2011, 1, 1, 1, 1), ZoneOffset.ofHours(0));
-    private static final ZonedDateTime SHOWS_ZONE_SAMPLE_TEMPORAL_2 = ZonedDateTime.of(
+    private static final ZonedDateTime SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_2 = ZonedDateTime.of(
             LocalDateTime.of(2011, 1, 1, 1, 1), ZoneOffset.ofHours(1));
 
-    private boolean showsZone(DateTimeFormatter dateTimeFormatter) {
-        return !dateTimeFormatter.format(SHOWS_ZONE_SAMPLE_TEMPORAL_1)
-                .equals(dateTimeFormatter.format(SHOWS_ZONE_SAMPLE_TEMPORAL_2));
+    private boolean showsOffsetOrZone(DateTimeFormatter dateTimeFormatter) {
+        return !dateTimeFormatter.format(SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_1)
+                .equals(dateTimeFormatter.format(SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_2));
     }
 
     private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
diff --git a/src/main/java/freemarker/core/MissingTimeZoneParserPolicy.java b/src/main/java/freemarker/core/MissingTimeZoneParserPolicy.java
new file mode 100644
index 0000000..c4d7b68
--- /dev/null
+++ b/src/main/java/freemarker/core/MissingTimeZoneParserPolicy.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import java.time.OffsetDateTime;
+
+import freemarker.template.Configuration;
+
+/**
+ * Used as a parameter to {@link TemplateTemporalFormat#parse(String, MissingTimeZoneParserPolicy)}, specifies what to
+ * do if we have to parse a string that contains no time zone or offset information to a non-local {@code java.time}
+ * temporal (like to {@link OffsetDateTime}).
+ *
+ * <p>There's no {@link Configuration} setting for this. Instead, the build-ins that parse to given non-local temporal
+ * type have 3 variants, one for each policy. For example, in the case of parsing a string to {@link OffsetDateTime},
+ * {@code ?offset_date_time} uses {@link #ASSUME_CURRENT_TIME_ZONE}, {@code ?offset_or_local_date_time} uses {@link
+ * #FALL_BACK_TO_LOCAL_TEMPORAL}, and {@code ?unambiguous_offset_date_time} uses {@link #FAIL}.
+ *
+ * <p>This is not used when parsing to {@link java.util.Date}, and the policy there is always effectively
+ * {@link #ASSUME_CURRENT_TIME_ZONE}.
+ *
+ * @since 2.3.32
+ */
+public enum MissingTimeZoneParserPolicy {
+    /**
+     * Use {@link Environment#getTimeZone()}.
+     */
+    ASSUME_CURRENT_TIME_ZONE,
+    /**
+     * Return a local temporal instead of the requested type.
+     */
+    FALL_BACK_TO_LOCAL_TEMPORAL,
+    /**
+     * Give up with error.
+     */
+    FAIL
+}
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index 75eede4..2c1dc26 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -81,7 +81,10 @@ public abstract class TemplateTemporalFormat extends TemplateValueFormat {
      *         a {@link Temporal}. {@link TemplateTemporalModel} is used if you have to attach some application-specific
      *         meta-information that's also extracted during {@link #formatToPlainText(TemplateTemporalModel)} (so if
      *         you format something and then parse it, you get back an equivalent result). It can't be {@code null}.
+     *
+     * @throws ParsingNotSupportedException If this format doesn't implement parsing.
      */
-    public abstract Object parse(String s) throws TemplateValueFormatException;
+    public abstract Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy)
+            throws TemplateValueFormatException;
 
 }
diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
index ab39c97..3243572 100644
--- a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
+++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java
@@ -35,8 +35,6 @@ 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.
  */
@@ -64,7 +62,7 @@ class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory {
         final DateTimeFormatter dateTimeFormatter;
         final DateTimeFormatter parserDateTimeFormatter;
         final String description;
-        temporalClass = TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        temporalClass = _TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
         if (temporalClass == LocalTime.class || temporalClass == OffsetTime.class) {
             dateTimeFormatter = ISO8601_TIME_FORMAT;
             parserDateTimeFormatter = PARSER_ISO8601_EXTENDED_TIME_FORMAT;
diff --git a/src/main/java/freemarker/core/_MessageUtil.java b/src/main/java/freemarker/core/_MessageUtil.java
index 0d5093d..112399f 100644
--- a/src/main/java/freemarker/core/_MessageUtil.java
+++ b/src/main/java/freemarker/core/_MessageUtil.java
@@ -54,6 +54,11 @@ public class _MessageUtil {
             + "to specify which fields to display. "
     };
 
+    static final String FAIL_MISSING_TIME_ZONE_PARSER_POLICY_ERROR_DETAIL
+            = "The parsed string doesn't contain time zone or offset, and the specified policy is "
+                    + "to fail in that case (see " + MissingTimeZoneParserPolicy.class.getName()
+                    + "." + MissingTimeZoneParserPolicy.FAIL + ").";
+
     static final String EMBEDDED_MESSAGE_BEGIN = "---begin-message---\n";
 
     static final String EMBEDDED_MESSAGE_END = "\n---end-message---";
diff --git a/src/main/java/freemarker/template/utility/TemporalUtils.java b/src/main/java/freemarker/core/_TemporalUtils.java
similarity index 94%
rename from src/main/java/freemarker/template/utility/TemporalUtils.java
rename to src/main/java/freemarker/core/_TemporalUtils.java
index 3498ff5..8aa38f8 100644
--- a/src/main/java/freemarker/template/utility/TemporalUtils.java
+++ b/src/main/java/freemarker/core/_TemporalUtils.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-package freemarker.template.utility;
+package freemarker.core;
 
 import java.lang.reflect.Modifier;
 import java.text.SimpleDateFormat;
@@ -49,15 +49,16 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
-import freemarker.core._JavaTimeBugUtils;
 import freemarker.template.Configuration;
+import freemarker.template.utility.StringUtil;
 
 /**
+ * For internal use only; don't depend on this, there's no backward compatibility guarantee at all!
  * Static utilities related to {@link Temporal}-s, and other {@code java.time} classes.
  *
  * @since 2.3.32
  */
-public final class TemporalUtils {
+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<>();
@@ -65,7 +66,7 @@ public final class TemporalUtils {
         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(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);
@@ -75,6 +76,7 @@ public final class TemporalUtils {
     /**
      * {@link Temporal} subclasses directly suppoerted by FreeMarker.
      */
+    // Not private because of tests
     static final List<Class<? extends Temporal>> SUPPORTED_TEMPORAL_CLASSES = Arrays.asList(
             Instant.class,
             LocalDate.class,
@@ -86,10 +88,11 @@ public final class TemporalUtils {
             Year.class,
             YearMonth.class);
 
+    // Not private because of tests
     static final boolean SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL = SUPPORTED_TEMPORAL_CLASSES.stream()
             .allMatch(cl -> (cl.getModifiers() & Modifier.FINAL) == Modifier.FINAL);
 
-    private TemporalUtils() {
+    private _TemporalUtils() {
         throw new AssertionError();
     }
 
@@ -100,8 +103,7 @@ public final class TemporalUtils {
     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);
+            Class<? extends Temporal> normalizedTemporalClass = normalizeSupportedTemporalClass(temporalClass);
             if (temporalClass != normalizedTemporalClass) {
                 temporalQuery = TEMPORAL_CLASS_TO_QUERY_MAP.get(normalizedTemporalClass);
             }
@@ -432,8 +434,6 @@ public final class TemporalUtils {
      * a future Java version, use this method before using {@code ==}.
      *
      * @throws IllegalArgumentException If the temporal class is not currently supported by FreeMarker.
-     *
-     * @since 2.3.32
      */
     public static Class<? extends Temporal> normalizeSupportedTemporalClass(Class<? extends Temporal> temporalClass) {
         if (SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL) {
@@ -467,8 +467,6 @@ public final class TemporalUtils {
      * Tells if the temporal class is one that doesn't store, nor have an implied time zone or offset.
      *
      * @throws IllegalArgumentException If the temporal class is not currently supported by FreeMarker.
-     *
-     * @since 2.3.32
      */
     public static boolean isLocalTemporalClass(Class<? extends Temporal> temporalClass) {
         temporalClass = normalizeSupportedTemporalClass(temporalClass);
@@ -482,6 +480,26 @@ public final class TemporalUtils {
     }
 
     /**
+     * Returns the local variation of a non-local class, or {@code null} if no local pair is known, or the class is not
+     * recognized .
+     *
+     * @throws IllegalArgumentException If the temporal class is not currently supported by FreeMarker.
+     */
+    public static Class<? extends Temporal> getLocalTemporalClassForNonLocal(Class<? extends Temporal> temporalClass) {
+        temporalClass = normalizeSupportedTemporalClass(temporalClass);
+        if (temporalClass == OffsetDateTime.class) {
+            return LocalDateTime.class;
+        }
+        if (temporalClass == ZonedDateTime.class) {
+            return LocalDateTime.class;
+        }
+        if (temporalClass == OffsetTime.class) {
+            return LocalTime.class;
+        }
+        return null;
+    }
+
+    /**
      * Returns the FreeMarker configuration format setting name for a temporal class.
      *
      * @throws IllegalArgumentException If {@link temporalClass} is not a supported {@link Temporal} subclass.
@@ -515,4 +533,5 @@ public final class TemporalUtils {
             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
index 4fcb177..75518fa 100644
--- a/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
+++ b/src/test/java/freemarker/core/AbstractTemporalFormatTest.java
@@ -20,6 +20,8 @@
 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;
@@ -65,9 +67,44 @@ public abstract class AbstractTemporalFormatTest {
         return sb.toString();
     }
 
+    /**
+     * Same as {@link #assertParsingResults(Consumer, MissingTimeZoneParserPolicy, Object...)} with 2nd argument {@link
+     * MissingTimeZoneParserPolicy#ASSUME_CURRENT_TIME_ZONE}.
+     */
     static protected void assertParsingResults(
             Consumer<Configurable> configurator,
-            Object... stringsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+            Object... inputsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+        assertParsingResults(
+                configurator,
+                MissingTimeZoneParserPolicy.ASSUME_CURRENT_TIME_ZONE,
+                inputsAndExpectedResults);
+    }
+
+    /**
+     * Same as {@link #assertParsingResults(Consumer, MissingTimeZoneParserPolicy, Object...)}, but 2nd argument going
+     * through all {@link MissingTimeZoneParserPolicy} values.
+     */
+    static protected void assertParsingResultsWithAllMissingTimeZonePolicies(
+            Consumer<Configurable> configurator,
+            Object... inputsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+        for (MissingTimeZoneParserPolicy missingTimeZoneParserPolicy : MissingTimeZoneParserPolicy.values()) {
+            assertParsingResults(
+                    configurator,
+                    MissingTimeZoneParserPolicy.ASSUME_CURRENT_TIME_ZONE,
+                    inputsAndExpectedResults);
+        }
+    }
+
+    /**
+     * @param inputsAndExpectedResults
+     *         Repeats this pattern: Parsed string ({@link String}), optional target temporal class ({@link Class}),
+     *         expected result ({@link Temporal}). If the target temporal class is left out, it will detect it from the
+     *         type of the expected result. ,
+     */
+    static protected void assertParsingResults(
+            Consumer<Configurable> configurator,
+            MissingTimeZoneParserPolicy missingTimeZoneParserPolicy,
+            Object... inputsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
         Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
         conf.setTimeZone(DateUtil.UTC);
         conf.setLocale(Locale.US);
@@ -81,45 +118,76 @@ public abstract class AbstractTemporalFormatTest {
             throw new UncheckedIOException(e);
         }
 
-        if (stringsAndExpectedResults.length % 2 != 0) {
-            throw new IllegalArgumentException(
-                    "stringsAndExpectedResults.length must be even, but was " + stringsAndExpectedResults.length + ".");
+        boolean hasTargetClasses = inputsAndExpectedResults.length >= 2 && inputsAndExpectedResults[1] instanceof Class;
+
+        if (inputsAndExpectedResults.length == 0) {
+            throw new IllegalArgumentException("inputsAndExpectedResults can't be empty.");
         }
-        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
-            Object value = stringsAndExpectedResults[i];
+        int i = 0;
+        while (i < inputsAndExpectedResults.length) {
+            Object value = inputsAndExpectedResults[i];
+
             if (!(value instanceof String)) {
-                throw new IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a String");
+                throw new IllegalArgumentException("inputsAndExpectedResults[" + i + "] should be a "
+                        + "String, but it's this " + ClassUtil.getShortClassNameOfObject(value) + ": " + value);
             }
-            String string = (String) value;
-
-            value = stringsAndExpectedResults[i + 1];
-            if (!(value instanceof Temporal)) {
-                throw new IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be a Temporal");
+            String stringToParse = (String) value;
+            i++;
+
+            final Class<? extends Temporal> temporalClass;
+            final Temporal expectedResult;
+            if (i == inputsAndExpectedResults.length) {
+                throw new IllegalArgumentException("inputsAndExpectedResults[" + i + "] is out of array bounds; "
+                        + "expecting a Temporal or Class<? extends Temporal> there.");
+            }
+            value = inputsAndExpectedResults[i];
+            if (value instanceof Temporal) {
+                expectedResult = (Temporal) value;
+                temporalClass = expectedResult.getClass();
+                i++;
+            } else if (value instanceof Class) {
+                temporalClass = (Class<? extends Temporal>) value;
+                i++;
+
+                if (i == inputsAndExpectedResults.length) {
+                    throw new IllegalArgumentException("inputsAndExpectedResults[" + i + "] is out of array bounds; "
+                            + "expecting a Temporal there.");
+                }
+                value = inputsAndExpectedResults[i];
+                if (!(value instanceof Temporal)) {
+                    throw new IllegalArgumentException("inputsAndExpectedResults[" + i + "] should be a "
+                            + "Temporal, but it's this " + ClassUtil.getShortClassNameOfObject(value) + ": " + value);
+                }
+                expectedResult = (Temporal) value;
+                i++;
+            } else {
+                throw new IllegalArgumentException("inputsAndExpectedResults[" + i + "] should be a "
+                        + "Temporal or Class<? extends Temporal>, but it's this "
+                        + ClassUtil.getShortClassNameOfObject(value) + ": " + value);
             }
-            Temporal expectedResult = (Temporal) value;
 
-            Class<? extends Temporal> temporalClass = expectedResult.getClass();
             TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
 
             Temporal actualResult;
             {
-                Object actualResultObject = templateTemporalFormat.parse(string);
+                Object actualResultObject = templateTemporalFormat.parse(stringToParse, missingTimeZoneParserPolicy);
                 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: "
+                            "Parsing result of " + jQuote(stringToParse) + " is not of an expected type: "
                                     + ClassUtil.getShortClassNameOfObject(actualResultObject));
                 }
             }
 
             if (!expectedResult.equals(actualResult)) {
                 throw new AssertionError(
-                        "Parsing result of " + jQuote(string) + " "
+                        "Parsing result of " + jQuote(stringToParse) + " "
                                 + "(with temporalFormat[" + temporalClass.getSimpleName() + "]="
                                 + jQuote(env.getTemporalFormat(temporalClass)) + ", "
+                                + "missingTimeZoneParserPolicy=" + missingTimeZoneParserPolicy + ", "
                                 + "timeZone=" + jQuote(env.getTimeZone().toZoneId()) + ", "
                                 + "locale=" + jQuote(env.getLocale()) + ") "
                                 + "differs from expected.\n"
@@ -131,10 +199,23 @@ public abstract class AbstractTemporalFormatTest {
 
     static protected void assertParsingFails(
             Consumer<Configurable> configurator,
-            String parsed,
+            String stringToParse,
             Class<? extends Temporal> temporalClass,
             Consumer<TemplateValueFormatException> exceptionAssertions) throws TemplateException,
             TemplateValueFormatException {
+        assertParsingFails(
+                configurator,
+                MissingTimeZoneParserPolicy.ASSUME_CURRENT_TIME_ZONE,
+                stringToParse, temporalClass,
+                exceptionAssertions);
+    }
+
+    static protected void assertParsingFails(
+            Consumer<Configurable> configurator,
+            MissingTimeZoneParserPolicy missingTimeZoneParserPolicy,
+            String stringToParse, Class<? extends Temporal> temporalClass,
+            Consumer<TemplateValueFormatException> exceptionAssertions) throws TemplateException,
+            TemplateValueFormatException {
         Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
         conf.setTimeZone(DateUtil.UTC);
         conf.setLocale(Locale.US);
@@ -151,11 +232,20 @@ public abstract class AbstractTemporalFormatTest {
         TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
 
         try {
-            templateTemporalFormat.parse(parsed);
-            fail("Parsing " + jQuote(parsed) + " with " + templateTemporalFormat + " should have failed.");
+            templateTemporalFormat.parse(stringToParse, missingTimeZoneParserPolicy);
+            fail("Parsing " + jQuote(stringToParse) + " with " + templateTemporalFormat + " should have failed.");
         } catch (TemplateValueFormatException e) {
             exceptionAssertions.accept(e);
         }
     }
 
+    protected static void assertMissingTimeZoneFailPolicyTriggered(TemplateValueFormatException e) {
+        assertThat(
+                e.getMessage(),
+                allOf(
+                        containsStringIgnoringCase("doesn't contain time zone or offset"),
+                        containsString(MissingTimeZoneParserPolicy.class.getName() + "."
+                                + MissingTimeZoneParserPolicy.FAIL)));
+    }
+
 }
diff --git a/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java b/src/test/java/freemarker/core/CustomTemplateTemporalFormatTest.java
similarity index 93%
rename from src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
rename to src/test/java/freemarker/core/CustomTemplateTemporalFormatTest.java
index 92b2a4a..2eb887b 100644
--- a/src/test/java/freemarker/core/TemporalFormatWithCustomFormatTest.java
+++ b/src/test/java/freemarker/core/CustomTemplateTemporalFormatTest.java
@@ -33,9 +33,9 @@ import freemarker.template.Configuration;
 import freemarker.test.TemplateTest;
 
 /**
- * Like {@link TemporalFormatWithJavaFormatTest}, but this one contains the tests that utilize {@link TemplateTest}.
+ * Like {@link JavaTemplateTemporalFormatTest}, but this one contains the tests that utilize {@link TemplateTest}.
  */
-public class TemporalFormatWithCustomFormatTest extends TemplateTest {
+public class CustomTemplateTemporalFormatTest extends TemplateTest {
 
     @Before
     public void setup() {
diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
index b3d6c5a..4c5174d 100644
--- a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
@@ -78,7 +78,7 @@ public class EpochMillisDivTemplateTemporalFormatFactory extends TemplateTempora
         }
 
         @Override
-        public Object parse(String s) throws TemplateValueFormatException {
+        public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException {
             throw new ParsingNotSupportedException("Parsing is not implement for this test class");
         }
 
diff --git a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
index a88c905..4eaf410 100644
--- a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
@@ -64,7 +64,7 @@ public class EpochMillisTemplateTemporalFormatFactory extends TemplateTemporalFo
         }
         
         @Override
-        public Object parse(String s) throws TemplateValueFormatException {
+        public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException {
             throw new ParsingNotSupportedException("Parsing is not implement for this test class");
         }
 
diff --git a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
index cb9a954..36c3c7e 100644
--- a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
@@ -60,7 +60,7 @@ public class HTMLISOTemplateTemporalFormatFactory extends TemplateTemporalFormat
         }
 
         @Override
-        public Object parse(String s) throws TemplateValueFormatException {
+        public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException {
             throw new ParsingNotSupportedException("Parsing is not implement for this test class");
         }
 
diff --git a/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java b/src/test/java/freemarker/core/ISOLikeTemplateTemporalFormatTest.java
similarity index 55%
rename from src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
rename to src/test/java/freemarker/core/ISOLikeTemplateTemporalFormatTest.java
index 27a4bf3..7590d62 100644
--- a/src/test/java/freemarker/core/TemporalFormatWithIsoFormatTest.java
+++ b/src/test/java/freemarker/core/ISOLikeTemplateTemporalFormatTest.java
@@ -20,6 +20,7 @@
 package freemarker.core;
 
 import static freemarker.template.utility.StringUtil.*;
+import static freemarker.test.hamcerst.Matchers.*;
 import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
 
@@ -46,11 +47,17 @@ import org.junit.Test;
 import freemarker.template.TemplateException;
 import freemarker.template.utility.DateUtil;
 
-public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest {
+public class ISOLikeTemplateTemporalFormatTest extends AbstractTemporalFormatTest {
+
+    private final static TimeZone TIME_ZONE = TimeZone.getTimeZone("America/New_York");
 
     private static final Consumer<Configurable> ISO_DATE_CONFIGURATOR = conf -> conf.setDateFormat("iso");
     private static final Consumer<Configurable> ISO_TIME_CONFIGURATOR = conf -> conf.setTimeFormat("iso");
-    private static final Consumer<Configurable> ISO_DATE_TIME_CONFIGURATOR = conf -> conf.setDateTimeFormat("iso");
+    private static final Consumer<Configurable> ISO_DATE_TIME_CONFIGURATOR = conf -> {
+        conf.setDateTimeFormat("iso");
+        conf.setTimeZone(TIME_ZONE);
+        conf.setLocale(Locale.GERMANY); // So if the decimal separator has a problem, we will notice
+    };
 
     private static Consumer<Configurable> isoDateTimeConfigurator(TimeZone timeZone) {
         return conf -> { ISO_DATE_TIME_CONFIGURATOR.accept(conf); conf.setTimeZone(timeZone); };
@@ -112,7 +119,7 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
                 "2021-12-11T13:01:02",
                 formatTemporal(
                         ISO_DATE_TIME_CONFIGURATOR,
-                        LocalDateTime.of(2021, 12, 11, 13, 1, 2, 0)));
+                        LocalDateTime.of(2021, 12, 11, 13, 1, 2)));
         assertEquals(
                 "2021-12-11T13:01:02.0123",
                 formatTemporal(
@@ -262,44 +269,46 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
 
     @Test
     public void testParseOffsetDateTime() throws TemplateException, TemplateValueFormatException {
-        testParseOffsetDateTimeAndInstant(OffsetDateTime.class);
+        testParseNonLocalDateTimeAndInstant(OffsetDateTime.class);
     }
 
     @Test
     public void testParseInstant() throws TemplateException, TemplateValueFormatException {
-        testParseOffsetDateTimeAndInstant(Instant.class);
+        testParseNonLocalDateTimeAndInstant(Instant.class);
     }
 
     @Test
     public void testParseZonedDateTime() throws TemplateException, TemplateValueFormatException {
-        testParseOffsetDateTimeAndInstant(ZonedDateTime.class);
+        testParseNonLocalDateTimeAndInstant(ZonedDateTime.class);
     }
 
-    private Temporal convertToClass(OffsetDateTime offsetDateTime, Class<? extends Temporal> temporalClass) {
+    private Temporal convertToClass(ZonedDateTime zonedDateTime, Class<? extends Temporal> temporalClass) {
+        if (temporalClass == ZonedDateTime.class) {
+            return zonedDateTime;
+        }
         if (temporalClass == OffsetDateTime.class) {
-            return offsetDateTime;
+            return zonedDateTime.toOffsetDateTime();
         }
         if (temporalClass == Instant.class) {
-            return offsetDateTime.toInstant();
-        }
-        if (temporalClass == ZonedDateTime.class) {
-            return offsetDateTime.toZonedDateTime();
+            return zonedDateTime.toInstant();
         }
         throw new IllegalArgumentException();
     }
 
-    private void testParseOffsetDateTimeAndInstant(Class<? extends Temporal> temporalClass)
+    private void testParseNonLocalDateTimeAndInstant(Class<? extends Temporal> temporalClass)
             throws TemplateException, TemplateValueFormatException {
         // ISO extended and ISO basic format:
-        for (String parsedString : new String[]{"2021-12-11T13:01:02.0123Z", "20211211T130102.0123Z"}) {
+        for (String stringToParse : new String[]{"2021-12-11T13:01:02.0123Z", "20211211T130102.0123Z"}) {
             assertParsingResults(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
-                    OffsetDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC)) ;
+                    stringToParse,
+                    convertToClass(
+                            ZonedDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000, ZoneOffset.UTC),
+                            temporalClass));
         }
 
         // Optional parts:
-        for (String parsedString : new String[] {
+        for (String stringToParse : new String[] {
                 "2021-12-11T13:00:00.0+02:00",
                 "2021-12-11T13:00:00+02:00",
                 "2021-12-11T13:00+02",
@@ -311,74 +320,89 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
         }) {
             assertParsingResults(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
                     convertToClass(
-                            OffsetDateTime.of(2021, 12, 11, 13, 0, 0, 0, ZoneOffset.ofHours(2)),
+                            ZonedDateTime.of(2021, 12, 11, 13, 0, 0, 0, ZoneOffset.ofHours(2)),
                             temporalClass));
         }
 
         // Negative year:
-        for (String parsedString : new String[] {
+        for (String stringToParse : new String[] {
                 "-1000-02-03T04Z",
                 "-10000203T04Z"
         }) {
             assertParsingResults(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
                     convertToClass(
-                            OffsetDateTime.of(-1000, 2, 3, 4, 0, 0, 0, ZoneOffset.UTC),
+                            ZonedDateTime.of(-1000, 2, 3, 4, 0, 0, 0, ZoneOffset.UTC),
                             temporalClass));
         }
 
         // Hour 24:
-        for (String parsedString : new String[] {
+        for (String stringToParse : new String[] {
                 "2020-01-02T24Z",
                 "2020-01-02T24:00Z",
                 "2020-01-02T24:00:00Z",
                 "2020-01-02T24:00:00.0Z",
                 "2020-01-02T24:00:00.0+00",
-                // For local temporals only: "2020-01-02T24:00:00",
                 "20200102T24Z",
                 "20200102T2400Z",
                 "20200102T240000Z",
                 "20200102T240000.0Z",
                 "20200102T240000.0+00",
-                // For local temporals only: "20200102T240000"
         }) {
             assertParsingResults(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
                     convertToClass(
-                            OffsetDateTime.of(2020, 1, 3, 0, 0, 0, 0, ZoneOffset.UTC),
+                            ZonedDateTime.of(2020, 1, 3, 0, 0, 0, 0, ZoneOffset.UTC),
                             temporalClass));
         }
 
-        // Unlike for the Java format, for ISO we require the string to contain the offset for a non-local target type.
-        for (String parsedString : new String[] {
+        // MissingTimeZoneParserPolicy-es:
+        String[] localStringsToParse = {
                 "2020-01-02T03", "2020-01-02T03:00", "2020-01-02T03:00:00",
-                "20200102T03", "20200102T0300", "20200102T030000"}) {
-            assertParsingFails(
+                "20200102T03", "20200102T0300", "20200102T030000"};
+        for (String stringToParse : localStringsToParse) {
+            assertParsingResults(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
+                    convertToClass(
+                            ZonedDateTime.of(2020, 1, 2, 3, 0, 0, 0, TIME_ZONE.toZoneId()),
+                            temporalClass));
+        }
+        for (String stringToParse : localStringsToParse) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR, MissingTimeZoneParserPolicy.FALL_BACK_TO_LOCAL_TEMPORAL,
+                    stringToParse,
+                    LocalDateTime.of(2020, 1, 2, 3, 0, 0));
+        }
+        for (String stringToParse : localStringsToParse) {
+            assertParsingFails(
+                    ISO_DATE_TIME_CONFIGURATOR, MissingTimeZoneParserPolicy.FAIL,
+                    stringToParse,
                     temporalClass,
                     e -> assertThat(e.getMessage(), allOf(
-                                containsString(jQuote(parsedString)),
-                                containsString("time zone offset"),
-                                containsString(temporalClass.getSimpleName()))));
+                            containsString(jQuote(stringToParse)),
+                            containsString("time zone or offset"),
+                            containsString(temporalClass.getSimpleName()))));
         }
 
-        for (String parsedString : new String[] {
+        // Invalid strings:
+        for (String stringToParse : new String[] {
                 "2021-12-11", "20211211", "2021-12-11T", "2021-12-11T0Z",
+                "2021-12-11T0102Z", "20211211T01:02Z",
                 "2021-12-11T25Z", "2022-02-29T23Z", "2021-13-11T23Z"}) {
             assertParsingFails(
                     ISO_DATE_TIME_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
                     temporalClass,
                     e -> {
                         assertThat(e.getMessage(), allOf(
-                                containsString(jQuote(parsedString)),
+                                containsString(jQuote(stringToParse)),
                                 containsString(temporalClass.getSimpleName())));
-                        if (!parsedString.contains("T")) {
+                        if (!stringToParse.contains("T")) {
                             assertThat(e.getMessage(), containsString("\"T\""));
                         }
                     });
@@ -387,22 +411,302 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
 
     @Test
     public void testParseOffsetTime() throws TemplateException, TemplateValueFormatException {
-        // TODO [FREEMARKER-35]
+        // ISO extended and ISO basic format:
+        for (String stringToParse : new String[]{"13:01:02.0123Z", "130102.0123Z"}) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    OffsetTime.of(13, 1, 2, 12_300_000, ZoneOffset.UTC)) ;
+        }
+
+        // Optional parts:
+        for (String stringToParse : new String[] {
+                "13:00:00.0+02:00",
+                "13:00:00+02:00",
+                "13:00+02",
+                "13+02",
+                "130000.0+0200",
+                "130000+0200",
+                "1300+02",
+                "13+02",
+        }) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    OffsetTime.of(13, 0, 0, 0, ZoneOffset.ofHours(2)));
+        }
+
+        // Hour 24:
+        for (String stringToParse : new String[] {
+                "24Z",
+                "24:00Z",
+                "24:00:00Z",
+                "24:00:00.0Z",
+                "24:00:00.0+00",
+                "24Z",
+                "2400Z",
+                "240000Z",
+                "240000.0Z",
+                "240000.0+00",
+        }) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    OffsetTime.of(0, 0, 0, 0, ZoneOffset.UTC));
+        }
+
+        // MissingTimeZoneParserPolicy-es:
+        String[] localStringsToParse = {
+                "03", "03:00", "03:00:00", "03:00:00.0",
+                "0300", "030000", "030000.0"};
+        for (String stringToParse : localStringsToParse) {
+            assertParsingFails(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    OffsetTime.class,
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsStringIgnoringCase("daylight saving"),
+                            containsString(OffsetTime.class.getSimpleName()))));
+        }
+        for (String stringToParse : localStringsToParse) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR, MissingTimeZoneParserPolicy.FALL_BACK_TO_LOCAL_TEMPORAL,
+                    stringToParse,
+                    LocalTime.of(3, 0, 0));
+        }
+        for (String stringToParse : localStringsToParse) {
+            assertParsingFails(
+                    ISO_TIME_CONFIGURATOR, MissingTimeZoneParserPolicy.FAIL,
+                    stringToParse,
+                    OffsetTime.class,
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsString("time zone or offset"),
+                            containsString(OffsetTime.class.getSimpleName()))));
+        }
+
+        // Invalid strings:
+        for (String stringToParse : new String[] {"Z", "1Z", "T01Z", "25Z", "1161Z", "01:02:03:00Z", "20210101T01Z"}) {
+            assertParsingFails(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    OffsetTime.class,
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsString(OffsetTime.class.getSimpleName()))));
+        }
     }
 
     @Test
     public void testParseLocalDateTime() throws TemplateException, TemplateValueFormatException {
-        // TODO [FREEMARKER-35]
+        // ISO extended and ISO basic format:
+        for (String stringToParse : new String[]{"2021-12-11T13:01:02.0123", "20211211T130102.0123"}) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.of(2021, 12, 11, 13, 1, 2, 12_300_000)) ;
+        }
+
+        // Optional parts:
+        for (String stringToParse : new String[] {
+                "2021-12-11T13:00:00.0",
+                "2021-12-11T13:00:00",
+                "2021-12-11T13:00",
+                "2021-12-11T13",
+                "20211211T130000.0",
+                "20211211T130000",
+                "20211211T1300",
+                "20211211T13",
+        }) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.of(2021, 12, 11, 13, 0, 0));
+        }
+
+        // Negative year:
+        for (String stringToParse : new String[] {
+                "-1000-02-03T04Z",
+                "-10000203T04Z"
+        }) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.of(-1000, 2, 3, 4, 0, 0));
+        }
+
+        // Hour 24:
+        for (String stringToParse : new String[] {
+                "2020-01-02T24",
+                "2020-01-02T24:00",
+                "2020-01-02T24:00:00",
+                "2020-01-02T24:00:00.0",
+                "20200102T24",
+                "20200102T2400",
+                "20200102T240000",
+                "20200102T240000.0",
+        }) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.of(2020, 1, 3, 0, 0, 0));
+        }
+
+        // MissingTimeZoneParserPolicy-es:
+        String[] localStringsToParse = {
+                "2020-01-02T03", "2020-01-02T03:00", "2020-01-02T03:00:00",
+                "20200102T03", "20200102T0300", "20200102T030000"};
+        for (MissingTimeZoneParserPolicy missingTimeZoneParserPolicy : MissingTimeZoneParserPolicy.values()) {
+            for (String stringToParse : localStringsToParse) {
+                assertParsingResults(
+                        ISO_DATE_TIME_CONFIGURATOR, missingTimeZoneParserPolicy,
+                        stringToParse,
+                        LocalDateTime.of(2020, 1, 2, 3, 0));
+            }
+        }
+
+        // Offset is ignored:
+        for (String stringToParse : new String[] {
+                "2021-12-11T03:04:05Z", "2021-12-11T03:04:05+01", "2021-12-11T03:04:05-01:30"}) {
+            assertParsingResults(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.of(2021, 12, 11, 3, 4, 5));
+        }
+
+        // Invalid strings:
+        for (String stringToParse : new String[] {
+                "2021-12-11", "20211211", "2021-12-11T", "2021-12-11T0",
+                "2021-12-11T0102", "20211211T01:02",
+                "2021-12-11T25", "2022-02-29T23", "2021-13-11T23"}) {
+            assertParsingFails(
+                    ISO_DATE_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalDateTime.class,
+                    e -> {
+                        assertThat(e.getMessage(), allOf(
+                                containsString(jQuote(stringToParse)),
+                                containsString(LocalDateTime.class.getSimpleName())));
+                        if (!stringToParse.contains("T")) {
+                            assertThat(e.getMessage(), containsString("\"T\""));
+                        }
+                    });
+        }
     }
 
     @Test
     public void testParseLocalDate() throws TemplateException, TemplateValueFormatException {
-        // TODO [FREEMARKER-35]
+        // ISO extended and ISO basic format:
+        for (String stringToParse : new String[]{"2021-12-11", "20211211"}) {
+            assertParsingResults(
+                    ISO_DATE_CONFIGURATOR,
+                    stringToParse,
+                    LocalDate.of(2021, 12, 11)) ;
+        }
+
+        // Negative year:
+        for (String stringToParse : new String[] {
+                "-1000-02-03",
+                "-10000203"
+        }) {
+            assertParsingResults(
+                    ISO_DATE_CONFIGURATOR,
+                    stringToParse,
+                    LocalDate.of(-1000, 2, 3));
+        }
+
+        // MissingTimeZoneParserPolicy-es:
+        for (MissingTimeZoneParserPolicy missingTimeZoneParserPolicy : MissingTimeZoneParserPolicy.values()) {
+            assertParsingResults(
+                    ISO_DATE_CONFIGURATOR, missingTimeZoneParserPolicy,
+                    "20200102",
+                    LocalDate.of(2020, 1, 2));
+        }
+
+        // Invalid strings:
+        for (String stringToParse : new String[] {
+                "2021-12-11Z", "2021-12-11T", "2021-1211",
+                "2022-02-29", "2021-13-11"}) {
+            assertParsingFails(
+                    ISO_DATE_CONFIGURATOR,
+                    stringToParse,
+                    LocalDate.class,
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsString(LocalDate.class.getSimpleName()))));
+        }
     }
 
     @Test
     public void testParseLocalTime() throws TemplateException, TemplateValueFormatException {
-        // TODO [FREEMARKER-35]
+        // ISO extended and ISO basic format:
+        for (String stringToParse : new String[]{"13:01:02.0123", "130102.0123"}) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalTime.of(13, 1, 2, 12_300_000));
+        }
+
+        // Optional parts:
+        for (String stringToParse : new String[] {
+                "13:00:00.0",
+                "13:00:00",
+                "13:00",
+                "13",
+                "130000.0",
+                "130000",
+                "1300",
+        }) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalTime.of(13, 0, 0));
+        }
+
+        // Hour 24:
+        for (String stringToParse : new String[] {
+                "24",
+                "24:00",
+                "24:00:00",
+                "24:00:00.0",
+                "24:00:00.0",
+                "2400",
+                "240000",
+                "240000.0"
+        }) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalTime.of(0, 0, 0));
+        }
+
+        // MissingTimeZoneParserPolicy-es:
+        for (MissingTimeZoneParserPolicy missingTimeZoneParserPolicy : MissingTimeZoneParserPolicy.values()) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR, missingTimeZoneParserPolicy,
+                    "03:04:05",
+                    LocalTime.of(3, 4, 5));
+        }
+
+        // Offset is ignored:
+        for (String stringToParse : new String[] {"03:04:05Z", "03:04:05+01", "03:04:05-01:30"}) {
+            assertParsingResults(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalTime.of(3, 4, 5));
+        }
+
+        // Invalid strings:
+        for (String stringToParse : new String[] {"", "1", "T01", "25", "1161", "01:02:03:00", "2021-01-01T01"}) {
+            assertParsingFails(
+                    ISO_TIME_CONFIGURATOR,
+                    stringToParse,
+                    LocalTime.class,
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsString(LocalTime.class.getSimpleName()))));
+        }
     }
 
     @Test
@@ -416,12 +720,9 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
                 ISO_DATE_TIME_CONFIGURATOR,
                 "2021-01",
                 Year.class,
-                e -> {
-                    assertThat(e.getMessage(), allOf(
-                            containsString(jQuote("2021-01")),
-                            containsString("Year")));
-                }
-        );
+                e -> assertThat(e.getMessage(), allOf(
+                        containsString(jQuote("2021-01")),
+                        containsString("Year"))));
     }
 
     @Test
@@ -435,17 +736,14 @@ public class TemporalFormatWithIsoFormatTest extends AbstractTemporalFormatTest
         assertParsingResults(ISO_YEAR_MONTH_CONFIGURATOR, "-1000-01", YearMonth.of(-1000, 1));
         assertParsingResults(ISO_YEAR_MONTH_CONFIGURATOR, "-100001", YearMonth.of(-1000, 1));
 
-        for (String parsedString : new String[] {"2021", "2021-12-11", "2021-13", "202113"}) {
+        for (String stringToParse : new String[] {"2021", "2021-12-11", "2021-13", "202113"}) {
             assertParsingFails(
                     ISO_YEAR_MONTH_CONFIGURATOR,
-                    parsedString,
+                    stringToParse,
                     YearMonth.class,
-                    e -> {
-                        assertThat(e.getMessage(), allOf(
-                                containsString(jQuote(parsedString)),
-                                containsString("YearMonth")));
-                    }
-            );
+                    e -> assertThat(e.getMessage(), allOf(
+                            containsString(jQuote(stringToParse)),
+                            containsString("YearMonth"))));
         }
     }
 
diff --git a/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
similarity index 72%
rename from src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
rename to src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
index 56fe2ab..a81841f 100644
--- a/src/test/java/freemarker/core/TemporalFormatWithJavaFormatTest.java
+++ b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
@@ -24,6 +24,7 @@ import static org.hamcrest.CoreMatchers.*;
 import static org.junit.Assert.*;
 
 import java.io.IOException;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -34,6 +35,7 @@ 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;
@@ -44,7 +46,7 @@ import freemarker.template.TemplateException;
 import freemarker.template.utility.DateUtil;
 import freemarker.test.hamcerst.Matchers;
 
-public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest {
+public class JavaTemplateTemporalFormatTest extends AbstractTemporalFormatTest {
 
     @Test
     public void testFormatOffsetTimeAndZones() throws TemplateException, IOException {
@@ -95,31 +97,33 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
         LocalDateTime winterLocalDateTime = LocalDateTime.of(winterLocalDate, localTime);
         OffsetDateTime winterOffsetDateTime = OffsetDateTime.of(winterLocalDateTime, ZoneOffset.ofHours(2));
         ZonedDateTime winterZonedDateTime = ZonedDateTime.of(winterLocalDateTime, nyZone.toZoneId());
+        Instant winterInstant = winterZonedDateTime.toInstant();
         LocalDateTime summerLocalDateTime = LocalDateTime.of(summerLocalDate, localTime);
         OffsetDateTime summerOffsetDateTime = OffsetDateTime.of(summerLocalDateTime, ZoneOffset.ofHours(2));
         ZonedDateTime summerZonedDateTime = ZonedDateTime.of(summerLocalDateTime, nyZone.toZoneId());
+        Instant summerInstant = summerZonedDateTime.toInstant();
 
         // If time zone (or offset) is not shown, the value is converted to the FreeMarker time zone:
         assertEquals(
-                "2021-06-30 10:30, 2021-06-30 09:30, 2021-06-30 15:30, "
-                        + "2021-12-30 10:30, 2021-12-30 08:30, 2021-12-30 15:30",
+                "2021-06-30 10:30, 2021-06-30 09:30, 2021-06-30 15:30, 2021-06-30 15:30, "
+                        + "2021-12-30 10:30, 2021-12-30 08:30, 2021-12-30 15:30, 2021-12-30 15:30",
                 formatTemporal(
                         conf -> {
                             conf.setDateTimeFormat("yyyy-MM-dd HH:mm");
                             conf.setTimeZone(gbZone);
                         },
-                        summerLocalDateTime, summerOffsetDateTime, summerZonedDateTime,
-                        winterLocalDateTime, winterOffsetDateTime, winterZonedDateTime));
+                        summerLocalDateTime, summerOffsetDateTime, summerZonedDateTime, summerInstant,
+                        winterLocalDateTime, winterOffsetDateTime, winterZonedDateTime, winterInstant));
         assertEquals(
-                "2021-06-30 10:30, 2021-06-30 08:30, 2021-06-30 14:30, "
-                        + "2021-12-30 10:30, 2021-12-30 08:30, 2021-12-30 15:30",
+                "2021-06-30 10:30, 2021-06-30 08:30, 2021-06-30 14:30, 2021-06-30 14:30, "
+                        + "2021-12-30 10:30, 2021-12-30 08:30, 2021-12-30 15:30, 2021-12-30 15:30",
                 formatTemporal(
                         conf -> {
                             conf.setDateTimeFormat("yyyy-MM-dd HH:mm");
                             conf.setTimeZone(DateUtil.UTC);
                         },
-                        summerLocalDateTime, summerOffsetDateTime, summerZonedDateTime,
-                        winterLocalDateTime, winterOffsetDateTime, winterZonedDateTime));
+                        summerLocalDateTime, summerOffsetDateTime, summerZonedDateTime, summerInstant,
+                        winterLocalDateTime, winterOffsetDateTime, winterZonedDateTime, winterInstant));
 
         // If the time zone (or offset) is shown, the value is not converted from its original time zone:
         assertEquals(
@@ -138,9 +142,7 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
     public void testFormatCanNotFormatLocalIfTimeZoneIsShown() {
         try {
             formatTemporal(
-                    conf -> {
-                        conf.setDateTimeFormat("yyyy-MM-dd HH:mmX");
-                    },
+                    conf -> conf.setDateTimeFormat("yyyy-MM-dd HH:mmX"),
                     LocalDateTime.of(2021, 10, 30, 1, 2));
             fail();
         } catch (TemplateException e) {
@@ -157,9 +159,7 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
     public void testFormatStylesAreNotSupportedForYear() {
         try {
             formatTemporal(
-                    conf -> {
-                        conf.setYearFormat("medium");
-                    },
+                    conf -> conf.setYearFormat("medium"),
                     Year.of(2021));
             fail();
         } catch (TemplateException e) {
@@ -194,7 +194,7 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
         ZoneId cfgZoneId = ZoneId.of("America/New_York");
         TimeZone cfgTimeZone = TimeZone.getTimeZone(cfgZoneId);
 
-        for (boolean winter : new boolean[] {true, false}) {
+        for (boolean winter : new boolean[]{true, false}) {
             final String stringToParse;
             final LocalDateTime localDateTime;
             if (winter) {
@@ -205,18 +205,44 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
                 localDateTime = LocalDateTime.of(2020, 07, 10, 13, 14);
             }
 
+            Consumer<Configurable> localLikeFormatConfigurator = conf -> {
+                conf.setDateTimeFormat("y-MM-dd HH:mm");
+                conf.setTimeZone(cfgTimeZone);
+            };
             {
                 ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, cfgZoneId);
                 assertParsingResults(
-                        conf -> {
-                            conf.setDateTimeFormat("y-MM-dd HH:mm");
-                            conf.setTimeZone(cfgTimeZone);
-                        },
+                        localLikeFormatConfigurator,
                         stringToParse, localDateTime,
                         stringToParse, zonedDateTime,
                         stringToParse, zonedDateTime.toInstant(),
                         stringToParse, zonedDateTime.toOffsetDateTime());
             }
+            {
+                ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, cfgZoneId);
+                assertParsingResults(
+                        localLikeFormatConfigurator,
+                        MissingTimeZoneParserPolicy.FALL_BACK_TO_LOCAL_TEMPORAL,
+                        stringToParse, localDateTime,
+                        stringToParse, ZonedDateTime.class, localDateTime,
+                        stringToParse, OffsetDateTime.class, localDateTime,
+                        stringToParse, Instant.class, localDateTime);
+            }
+            {
+                ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, cfgZoneId);
+                assertParsingResults(
+                        localLikeFormatConfigurator,
+                        MissingTimeZoneParserPolicy.FAIL,
+                        stringToParse, localDateTime);
+            }
+            for (Class<? extends Temporal> temporalClass
+                    : new Class[]{OffsetDateTime.class, ZonedDateTime.class, Instant.class}) {
+                assertParsingFails(
+                        localLikeFormatConfigurator,
+                        MissingTimeZoneParserPolicy.FAIL,
+                        stringToParse, temporalClass,
+                        JavaTemplateTemporalFormatTest::assertMissingTimeZoneFailPolicyTriggered);
+            }
 
             {
                 String stringToParseWithOffset = stringToParse + "+02";
@@ -274,34 +300,58 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
     @Test
     public void testParseDate() throws TemplateException, TemplateValueFormatException {
         LocalDate localDate = LocalDate.of(2020, 11, 10);
-        assertParsingResults(
+        assertParsingResultsWithAllMissingTimeZonePolicies(
                 conf -> conf.setDateFormat("y-MM-dd"),
                 "2020-11-10", localDate);
-        assertParsingResults(
+        assertParsingResultsWithAllMissingTimeZonePolicies(
                 conf -> conf.setDateFormat("yy-MM-dd"),
                 "20-11-10", localDate);
+
+        assertParsingFails(
+                conf -> conf.setDateFormat("yy-MM-dd"),
+                "20-13-01",
+                LocalDate.class,
+                e -> assertThat(e.getMessage(), containsStringIgnoringCase("month")));
     }
 
     @Test
     public void testParseLocalTime() throws TemplateException, TemplateValueFormatException {
         String stringToParse = "13:14";
 
-        assertParsingResults(
+        assertParsingResultsWithAllMissingTimeZonePolicies(
                 conf -> conf.setTimeFormat("HH:mm"),
                 stringToParse, LocalTime.of(13, 14));
 
-        assertParsingResults(
-                conf -> {
-                    conf.setTimeFormat("HH:mmX");
-                    conf.setTimeZone(TimeZone.getTimeZone("GMT+02"));
-                },
-                stringToParse + "+02", LocalTime.of(13, 14));
+        for (String offsetSuffix : new String[] {"+02", "-01"}) {
+            assertParsingResultsWithAllMissingTimeZonePolicies(
+                    conf -> {
+                        conf.setTimeFormat("HH:mmX");
+                        conf.setTimeZone(TimeZone.getTimeZone("GMT+02"));
+                    },
+                    stringToParse + offsetSuffix, LocalTime.of(13, 14));
+        }
+
+        assertParsingResultsWithAllMissingTimeZonePolicies(
+                conf -> conf.setTimeFormat("HH:mm:ss.SSS"),
+                "01:02:03.400", LocalTime.of(1, 2, 3, 400_000_000));
+
+        assertParsingResultsWithAllMissingTimeZonePolicies(
+                conf -> conf.setTimeFormat("hh:mm a"),
+                "01:02 AM", LocalTime.of(1, 2));
+        assertParsingResultsWithAllMissingTimeZonePolicies(
+                conf -> conf.setTimeFormat("hh:mm a"),
+                "01:02 PM", LocalTime.of(13, 2));
+
+        assertParsingFails(
+                conf -> conf.setTimeFormat("hh:mm a"),
+                "25:00", LocalTime.class,
+                e -> assertThat(e.getMessage(), containsStringIgnoringCase("hour")));
     }
 
     @Test
     public void testParseLocalization() throws TemplateException, TemplateValueFormatException {
         LocalDate localDate = LocalDate.of(2020, 11, 10);
-        for (Locale locale : new Locale[] {
+        for (Locale locale : new Locale[]{
                 Locale.CHINA,
                 Locale.GERMANY,
                 new Locale("th", "TH"), // Because of the Buddhist calendar
@@ -321,7 +371,7 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
         ZoneId zoneId = ZoneId.of("America/New_York");
         TimeZone timeZone = TimeZone.getTimeZone(zoneId);
 
-        assertParsingResults(
+        assertParsingResultsWithAllMissingTimeZonePolicies(
                 conf -> {
                     conf.setTimeFormat("HH:mmXX");
                     conf.setTimeZone(timeZone);
@@ -329,18 +379,27 @@ public class TemporalFormatWithJavaFormatTest extends AbstractTemporalFormatTest
                 "13:14+0130",
                 OffsetTime.of(LocalTime.of(13, 14), ZoneOffset.ofHoursMinutesSeconds(1, 30, 0)));
 
-        try {
-            assertParsingResults(
-                    conf -> {
-                        conf.setTimeFormat("HH:mm");
-                        conf.setTimeZone(timeZone);
-                    },
-                    "13:14",
-                    OffsetTime.now() /* Value doesn't matter, as parsing will fail */);
-            fail("OffsetTime parsing should have failed when offset is not specified");
-        } catch (InvalidFormatParametersException e) {
-            assertThat(e.getMessage(), Matchers.containsStringIgnoringCase("daylight saving"));
-        }
+        Consumer<Configurable> localLikeFormatConfigurator = conf -> {
+            conf.setTimeFormat("HH:mm");
+            conf.setTimeZone(timeZone);
+        };
+
+        assertParsingFails(
+                localLikeFormatConfigurator,
+                "13:14", OffsetTime.class,
+                e -> assertThat(e.getMessage(), Matchers.containsStringIgnoringCase("daylight saving")));
+
+        assertParsingResults(
+                localLikeFormatConfigurator,
+                MissingTimeZoneParserPolicy.FALL_BACK_TO_LOCAL_TEMPORAL,
+                "13:14", OffsetTime.class,
+                LocalTime.of(13, 14));
+
+        assertParsingFails(
+                localLikeFormatConfigurator,
+                MissingTimeZoneParserPolicy.FAIL,
+                "13:14", OffsetTime.class,
+                JavaTemplateTemporalFormatTest::assertMissingTimeZoneFailPolicyTriggered);
     }
 
 }
diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
index f8827e2..b58ecd4 100644
--- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
@@ -69,7 +69,7 @@ public class LocAndTZSensitiveTemplateTemporalFormatFactory extends TemplateTemp
         }
 
         @Override
-        public Object parse(String s) throws TemplateValueFormatException {
+        public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException {
             throw new ParsingNotSupportedException("Parsing is not implement for this test class");
         }
 
diff --git a/src/test/java/freemarker/template/utility/TemporalUtilsTest.java b/src/test/java/freemarker/core/_TemporalUtilsTest.java
similarity index 67%
rename from src/test/java/freemarker/template/utility/TemporalUtilsTest.java
rename to src/test/java/freemarker/core/_TemporalUtilsTest.java
index 2b5c06f..be258b2 100644
--- a/src/test/java/freemarker/template/utility/TemporalUtilsTest.java
+++ b/src/test/java/freemarker/core/_TemporalUtilsTest.java
@@ -17,11 +17,16 @@
  * under the License.
  */
 
-package freemarker.template.utility;
+package freemarker.core;
 
 import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
 
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZonedDateTime;
 import java.time.chrono.ChronoLocalDate;
 import java.time.temporal.Temporal;
 import java.util.HashSet;
@@ -31,21 +36,21 @@ import org.junit.Test;
 
 import freemarker.template.Configuration;
 
-public class TemporalUtilsTest {
+public class _TemporalUtilsTest {
 
     @Test
     public void testSupportedTemporalClassAreFinal() {
         assertTrue(
                 "FreeMarker was implemented with the assumption that temporal classes are final. While there "
                         + "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);
+                _TemporalUtils.SUPPORTED_TEMPORAL_CLASSES_ARE_FINAL);
     }
 
     @Test
     public void testGetTemporalFormat() {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
 
-        for (Class<? extends Temporal> supportedTemporalClass : TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+        for (Class<? extends Temporal> supportedTemporalClass : _TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
             assertNotNull(cfg.getTemporalFormat(supportedTemporalClass));
         }
 
@@ -63,20 +68,28 @@ public class TemporalUtilsTest {
 
         for (boolean camelCase : new boolean[] {false, true}) {
             Set<String> uniqueSettingNames = new HashSet<>();
-            for (Class<? extends Temporal> supportedTemporalClass : TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
+            for (Class<? extends Temporal> supportedTemporalClass : _TemporalUtils.SUPPORTED_TEMPORAL_CLASSES) {
                 uniqueSettingNames.add(
-                        TemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass, camelCase));
+                        _TemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass, camelCase));
             }
-            assertThat(uniqueSettingNames.size(), equalTo(TemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
+            assertThat(uniqueSettingNames.size(), equalTo(_TemporalUtils.SUPPORTED_TEMPORAL_CLASSES.size() - 4));
             assertTrue(uniqueSettingNames.stream().allMatch(it -> cfg.getSettingNames(camelCase).contains(it)));
         }
 
         try {
-            TemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class, false);
+            _TemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class, false);
             fail();
         } catch (IllegalArgumentException e) {
             // Expected
         }
     }
 
+    @Test
+    public void testGetLocalTemporalClassForNonLocal() {
+        assertThat(_TemporalUtils.getLocalTemporalClassForNonLocal(OffsetDateTime.class), equalTo(LocalDateTime.class));
+        assertThat(_TemporalUtils.getLocalTemporalClassForNonLocal(ZonedDateTime.class), equalTo(LocalDateTime.class));
+        assertThat(_TemporalUtils.getLocalTemporalClassForNonLocal(OffsetTime.class), equalTo(LocalTime.class));
+        assertNull(_TemporalUtils.getLocalTemporalClassForNonLocal(LocalDateTime.class));
+    }
+
 }
\ No newline at end of file
diff --git a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
index c11b6ef..df8e740 100644
--- a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
+++ b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
@@ -40,6 +40,7 @@ import org.hamcrest.Matchers;
 import org.junit.Test;
 
 import freemarker.core._JavaVersion;
+import freemarker.core._TemporalUtils;
 
 /**
  * Move pattern parsing related tests from {@link DateUtilTest} to here.
@@ -176,14 +177,14 @@ public class DateUtilsPatternParsingTest {
     @Test
     public void testInvalidPatternExceptions() {
         try {
-            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("y v", SAMPLE_LOCALE);
+            _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("y v", SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("\"v\""));
         }
 
         try {
-            TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", SAMPLE_LOCALE);
+            _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", SAMPLE_LOCALE);
             fail();
         } catch (IllegalArgumentException e) {
             assertThat(e.getMessage(), Matchers.containsString("4"));
@@ -195,7 +196,7 @@ public class DateUtilsPatternParsingTest {
         assertEquals(
                 LocalDateTime.of(2021, 12, 23, 1, 2, 3),
                 LocalDateTime.from(
-                        TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", SAMPLE_LOCALE)
+                        _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", SAMPLE_LOCALE)
                                 .parse("20211223010203")));
     }
 
@@ -263,7 +264,7 @@ public class DateUtilsPatternParsingTest {
         SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale);
         sdf.setTimeZone(timeZone);
 
-        DateTimeFormatter dtf = TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
+        DateTimeFormatter dtf = _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
 
         String sdfOutput = sdf.format(date);
         String dtfOutput = dtf.format(temporal);
@@ -302,7 +303,7 @@ public class DateUtilsPatternParsingTest {
 
     private LocalDate parseLocalDate(String pattern, String string, Locale locale) {
         return LocalDate.from(
-                TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale)
+                _TemporalUtils.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale)
                         .parse(string));
     }