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/01/02 22:47:07 UTC

[freemarker] 02/03: [FREEMARKER-35] Added date-time parsing to TemplateTemporalFormat interface, and to JavaTemplateTemporalFormat for now. Removed feature where OffsetTime formatting without showing the offset was possible if the timeZone had no daylight saving, as it made it too likely that an application that worked one day suddenly starts to fail. Also removed the logic where the OffsetTime format style was automatically incremented to include the zone (was especially confusing combined with parsing).

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

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

commit fffb61112716c048827cc72aab0a7711cb94c723
Author: ddekany <dd...@apache.org>
AuthorDate: Thu Dec 30 08:31:02 2021 +0100

    [FREEMARKER-35] Added date-time parsing to TemplateTemporalFormat interface, and to JavaTemplateTemporalFormat for now. Removed feature where OffsetTime formatting without showing the offset was possible if the timeZone had no daylight saving, as it made it too likely that an application that worked one day suddenly starts to fail. Also removed the logic where the OffsetTime format style was automatically incremented to include the zone (was especially confusing combined with parsing).
---
 src/main/java/freemarker/core/Environment.java     |   2 +-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |   5 +
 .../core/JavaTemplateTemporalFormat.java           | 202 ++++++++++++++-------
 .../freemarker/core/TemplateTemporalFormat.java    |  14 +-
 ...pochMillisDivTemplateTemporalFormatFactory.java |   5 +
 .../EpochMillisTemplateTemporalFormatFactory.java  |   5 +
 .../core/HTMLISOTemplateTemporalFormatFactory.java |   5 +
 ...ndTZSensitiveTemplateTemporalFormatFactory.java |   5 +
 .../java/freemarker/core/TemporalFormatTest.java   | 174 +++++++++++++++---
 .../test/templatesuite/templates/temporal.ftl      |  11 +-
 10 files changed, 327 insertions(+), 101 deletions(-)

diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java
index 2046396..24a0137 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -2330,7 +2330,7 @@ public final class Environment extends Configurable {
     }
 
     /**
-     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative
+     * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to more informative
      * {@link TemplateException}-s.
      */
     TemplateTemporalFormat getTemplateTemporalFormat(
diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 82be105..00b79f1 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -71,6 +71,11 @@ final class ISOLikeTemplateTemporalTemporalFormat extends TemplateTemporalFormat
     }
 
     @Override
+    public Object parse(String s) throws TemplateValueFormatException {
+        throw new ParsingNotSupportedException("To be implemented"); // TODO [FREEMARKER-35]
+    }
+
+    @Override
     public boolean isLocaleBound() {
         return false;
     }
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 47cd093..0e979f2 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -18,6 +18,8 @@
  */
 package freemarker.core;
 
+import static freemarker.template.utility.StringUtil.*;
+
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.LocalDate;
@@ -31,9 +33,15 @@ 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.TemporalAccessor;
+import java.time.temporal.TemporalQuery;
+import java.util.IdentityHashMap;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -42,7 +50,6 @@ import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
-import freemarker.template.utility.StringUtil;
 
 /**
  * See {@link JavaTemplateTemporalFormatFactory}.
@@ -54,7 +61,11 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     enum PreFormatValueConversion {
         INSTANT_TO_ZONED_DATE_TIME,
         SET_ZONE_FROM_OFFSET,
-        CONVERT_TO_CURRENT_ZONE
+        OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION
+    }
+
+    enum SpecialParsing {
+        OFFSET_TIME_DST_ERROR
     }
 
     static final String SHORT = "short";
@@ -71,19 +82,39 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
     private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile(
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
+    private static final Map<Class<? extends Temporal>, TemporalQuery<? extends Temporal>> TEMPORAL_CLASS_TO_QUERY_MAP;
+    static {
+        TEMPORAL_CLASS_TO_QUERY_MAP = new IdentityHashMap<>();
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Instant.class, Instant::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDate.class, LocalDate::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalDateTime.class, LocalDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(LocalTime.class, LocalTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetDateTime.class, JavaTemplateTemporalFormat::offsetDateTimeFrom);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(OffsetTime.class, OffsetTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(ZonedDateTime.class, ZonedDateTime::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(Year.class, Year::from);
+        TEMPORAL_CLASS_TO_QUERY_MAP.put(YearMonth.class, YearMonth::from);
+    }
+
     private final DateTimeFormatter dateTimeFormatter;
+    private final TemporalQuery<? extends Temporal> temporalQuery;
     private final ZoneId zoneId;
     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)
             throws InvalidFormatParametersException {
-        temporalClass = _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        this.temporalClass = _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+
+        temporalQuery = Objects.requireNonNull(TEMPORAL_CLASS_TO_QUERY_MAP.get(temporalClass));
 
         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) {
@@ -108,7 +139,7 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                 dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(datePartFormatStyle);
             } else {
                 throw new InvalidFormatParametersException(
-                        "Format styles (like " + StringUtil.jQuote(formatString) + ") is not supported for "
+                        "Format styles (like " + jQuote(formatString) + ") is not supported for "
                         + temporalClass.getName() + " values.");
             }
         } else {
@@ -125,13 +156,15 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         // Handling of time zone related edge cases
         if (isLocalTemporalClass(temporalClass)) {
             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) {
                     try {
-                        dateTimeFormatter.format(LOCAL_DATE_TIME_SAMPLE); // We only care if it throws exception
+                        dateTimeFormatter.format(LOCAL_DATE_TIME_SAMPLE); // We only care if it throws exception or not
                         break localFormatAttempt; // It worked
                     } catch (DateTimeException e) {
                         timePartFormatStyle = getLessVerboseStyle(timePartFormatStyle);
@@ -153,45 +186,46 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                     }
                 }
             }
-        } else {
+        } else { // is non-local temporal
             PreFormatValueConversion preFormatValueConversion;
-            nonLocalFormatAttempt: while (true) {
-                if (showsZone(dateTimeFormatter)) {
-                    if (temporalClass == Instant.class) {
-                        preFormatValueConversion = PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME;
-                    } else if (isFormatStyleString &&
-                            (temporalClass == OffsetDateTime.class || temporalClass == OffsetTime.class)) {
-                        preFormatValueConversion = PreFormatValueConversion.SET_ZONE_FROM_OFFSET;
-                    } else {
-                        preFormatValueConversion = null;
-                    }
+            SpecialParsing specialParsing;
+            if (showsZone(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 {
-                    if (temporalClass == OffsetTime.class && timeZone.useDaylightTime()) {
-                        if (isFormatStyleString) {
-                            // To find the closest style that already shows the offset
-                            timePartFormatStyle = getMoreVerboseStyle(timePartFormatStyle);
-                        }
-                        if (timePartFormatStyle == null) {
-                            throw new InvalidFormatParametersException(
-                                    "The format must show the time offset, as the current FreeMarker time zone, "
-                                            + StringUtil.jQuote(timeZone.getID()) +
-                                            ", may uses Daylight Saving Time, and thus "
-                                            + "it's not possible to convert the value to the local time in that zone, "
-                                            + "since we don't know the day.");
-                        }
-                        dateTimeFormatter = DateTimeFormatter.ofLocalizedTime(timePartFormatStyle);
-                        formatString = timePartFormatStyle.name().toLowerCase(Locale.ROOT);
-                        continue nonLocalFormatAttempt;
-                    } else {
-                        preFormatValueConversion = PreFormatValueConversion.CONVERT_TO_CURRENT_ZONE;
-                    }
+                    preFormatValueConversion = null;
+                    specialParsing = null;
                 }
-                break nonLocalFormatAttempt;
-            };
+                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;
+                } 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;
+                }
+            }
             this.preFormatValueConversion = preFormatValueConversion;
+            this.specialParsing = specialParsing;
         }
 
-        this.dateTimeFormatter = dateTimeFormatter.withLocale(locale);
+        dateTimeFormatter = dateTimeFormatter.withLocale(locale);
+        if (formatWithZone) {
+            dateTimeFormatter = dateTimeFormatter.withZone(timeZone.toZoneId());
+        }
+        this.dateTimeFormatter = dateTimeFormatter;
         this.formatString = formatString;
         this.zoneId = timeZone.toZoneId();
     }
@@ -201,36 +235,32 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         DateTimeFormatter dateTimeFormatter = this.dateTimeFormatter;
         Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
 
-        if (preFormatValueConversion == PreFormatValueConversion.INSTANT_TO_ZONED_DATE_TIME) {
-            temporal = ((Instant) temporal).atZone(zoneId);
-        } else if (preFormatValueConversion == PreFormatValueConversion.CONVERT_TO_CURRENT_ZONE) {
-            if (temporal instanceof Instant) {
-                temporal = ((Instant) temporal).atZone(zoneId);
-            } else if (temporal instanceof OffsetDateTime) {
-                temporal = ((OffsetDateTime) temporal).atZoneSameInstant(zoneId);
-            } else if (temporal instanceof ZonedDateTime) {
-                temporal = ((ZonedDateTime) temporal).withZoneSameInstant(zoneId);
-            } else if (temporal instanceof OffsetTime) {
-                // Because of logic in the constructor, this is only reached if the zone never uses Daylight Saving.
-                temporal = ((OffsetTime) temporal).withOffsetSameInstant(zoneId.getRules().getOffset(Instant.EPOCH));
-            } else {
-                throw new InvalidFormatParametersException(
-                        "Don't know how to convert value of type " + temporal.getClass().getName() + " to the current "
-                                + "FreeMarker time zone, " + StringUtil.jQuote(zoneId.getId()) + ", which is "
-                                + "needed to format with " + StringUtil.jQuote(formatString) + ".");
-            }
-        } else if (preFormatValueConversion == PreFormatValueConversion.SET_ZONE_FROM_OFFSET) {
-            // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such.
-            if (temporal instanceof OffsetDateTime) {
-                OffsetDateTime offsetDateTime = (OffsetDateTime) temporal;
-                temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
-            } else if (temporal instanceof OffsetTime) {
-                // There's no ZonedTime class, so we must manipulate the format.
-                dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
-            } else {
-                throw new IllegalArgumentException(
-                        "Formatter was created for OffsetTime or OffsetDateTime, but value was a "
-                                + ClassUtil.getShortClassNameOfObject(temporal));
+        if (preFormatValueConversion != null) {
+            switch (preFormatValueConversion) {
+                case INSTANT_TO_ZONED_DATE_TIME:
+                    // Typical date-time formats will fail with "UnsupportedTemporalTypeException: Unsupported field:
+                    // YearOfEra" if we leave the value as Instant. (But parse(String, Instant::from) has no similar
+                    // issue.)
+                    temporal = ((Instant) temporal).atZone(zoneId);
+                    break;
+                case SET_ZONE_FROM_OFFSET:
+                    // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such.
+                    if (temporal instanceof OffsetDateTime) {
+                        OffsetDateTime offsetDateTime = (OffsetDateTime) temporal;
+                        temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
+                    } else if (temporal instanceof OffsetTime) {
+                        // There's no ZonedTime class, so we must manipulate the format.
+                        dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
+                    } else {
+                        throw new IllegalArgumentException(
+                                "Formatter was created for OffsetTime or OffsetDateTime, but value was a "
+                                        + ClassUtil.getShortClassNameOfObject(temporal));
+                    }
+                    break;
+                case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION:
+                    throw newOffsetTimeWithoutOffsetOnTheFormatException();
+                default:
+                    throw new BugException();
             }
         }
 
@@ -241,6 +271,38 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
         }
     }
 
+    private static InvalidFormatParametersException newOffsetTimeWithoutOffsetOnTheFormatException() {
+        return new InvalidFormatParametersException(
+                "The time format must show the time offset when dealing with offset-time type, because in case the "
+                        + "current FreeMarker time zone uses Daylight Saving Time, it's impossible to convert between "
+                        + "offset-time, and the local-time, since we don't know the day.");
+    }
+
+    @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);
+        }
+    }
+
     @Override
     public String getDescription() {
         return formatString;
@@ -280,6 +342,10 @@ class JavaTemplateTemporalFormat extends TemplateTemporalFormat {
                 || normalizedTemporalClass == YearMonth.class;
     }
 
+    private static OffsetDateTime offsetDateTimeFrom(TemporalAccessor temporal) {
+        return ZonedDateTime.from(temporal).toOffsetDateTime();
+    }
+
     private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
         switch (style) {
             case SHORT:
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index e864487..aeaffd9 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -67,6 +67,18 @@ public abstract class TemplateTemporalFormat extends TemplateValueFormat {
      */
     public abstract boolean isTimeZoneBound();
 
-    // TODO [FREEMARKER-35] Add parse method
+    /**
+     * Parsers a string to a {@link Temporal}, according to this format. Some format implementations may throw
+     * {@link ParsingNotSupportedException} here.
+     *
+     * @param s
+     *            The string to parse
+     *
+     * @return The interpretation of the text either as a {@link Temporal} or {@link TemplateTemporalModel}. Typically,
+     *         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}.
+     */
+    public abstract Object parse(String s) throws TemplateValueFormatException;
 
 }
diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
index ff5ba63..b3d6c5a 100644
--- a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
@@ -78,6 +78,11 @@ public class EpochMillisDivTemplateTemporalFormatFactory extends TemplateTempora
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return false;
         }
diff --git a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
index a51b6ad..a88c905 100644
--- a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
@@ -62,6 +62,11 @@ public class EpochMillisTemplateTemporalFormatFactory extends TemplateTemporalFo
             }
             return String.valueOf(epochMillis);
         }
+        
+        @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
 
         @Override
         public boolean isLocaleBound() {
diff --git a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
index 5226fd2..cb9a954 100644
--- a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
@@ -60,6 +60,11 @@ public class HTMLISOTemplateTemporalFormatFactory extends TemplateTemporalFormat
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return false;
         }
diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
index 446f3c3..f8827e2 100644
--- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
@@ -69,6 +69,11 @@ public class LocAndTZSensitiveTemplateTemporalFormatFactory extends TemplateTemp
         }
 
         @Override
+        public Object parse(String s) throws TemplateValueFormatException {
+            throw new ParsingNotSupportedException("Parsing is not implement for this test class");
+        }
+
+        @Override
         public boolean isLocaleBound() {
             return true;
         }
diff --git a/src/test/java/freemarker/core/TemporalFormatTest.java b/src/test/java/freemarker/core/TemporalFormatTest.java
index 83268e0..ab6e479 100644
--- a/src/test/java/freemarker/core/TemporalFormatTest.java
+++ b/src/test/java/freemarker/core/TemporalFormatTest.java
@@ -19,6 +19,7 @@
 
 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.*;
@@ -32,9 +33,11 @@ import java.time.OffsetDateTime;
 import java.time.OffsetTime;
 import java.time.Year;
 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 +47,10 @@ import freemarker.template.Configuration;
 import freemarker.template.SimpleTemporal;
 import freemarker.template.Template;
 import freemarker.template.TemplateException;
+import freemarker.template.TemplateTemporalModel;
+import freemarker.template.utility.ClassUtil;
 import freemarker.template.utility.DateUtil;
+import freemarker.test.hamcerst.Matchers;
 
 public class TemporalFormatTest {
 
@@ -52,20 +58,7 @@ public class TemporalFormatTest {
     public void testOffsetTimeAndZones() throws TemplateException, IOException {
         OffsetTime offsetTime = OffsetTime.of(LocalTime.of(10, 0, 0), ZoneOffset.ofHours(1));
 
-        TimeZone zoneWithoutDST = TimeZone.getTimeZone("GMT+2");
-        assertFalse(zoneWithoutDST.useDaylightTime());
-
-        TimeZone zoneWithDST = TimeZone.getTimeZone("America/New_York");
-        assertTrue(zoneWithDST.useDaylightTime());
-
-        assertEquals(
-                "11:00",
-                formatTemporal(
-                        conf -> {
-                            conf.setTimeFormat("HH:mm");
-                            conf.setTimeZone(zoneWithoutDST);
-                        },
-                        offsetTime));
+        TimeZone timeZone = TimeZone.getTimeZone("America/New_York");
 
         try {
             assertEquals(
@@ -73,7 +66,7 @@ public class TemporalFormatTest {
                     formatTemporal(
                             conf -> {
                                 conf.setTimeFormat("HH:mm");
-                                conf.setTimeZone(zoneWithDST);
+                                conf.setTimeZone(timeZone);
                             },
                             offsetTime));
             fail();
@@ -86,18 +79,10 @@ public class TemporalFormatTest {
                 formatTemporal(
                         conf -> {
                             conf.setTimeFormat("HH:mmX");
-                            conf.setTimeZone(zoneWithDST);
+                            conf.setTimeZone(timeZone);
                         },
                         offsetTime));
 
-        assertEquals(
-                "10:00+01",
-                formatTemporal(
-                        conf -> {
-                            conf.setTimeFormat("HH:mmX");
-                            conf.setTimeZone(zoneWithoutDST);
-                        },
-                        offsetTime));
     }
 
     @Test
@@ -212,6 +197,82 @@ public class TemporalFormatTest {
         }
     }
 
+    @Test
+    public void testDateTimeParsing() throws TemplateException, TemplateValueFormatException {
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+
+        for (int i = 0; i < 2; i++) {
+            String stringToParse = i == 0 ? "2020-12-10 13:14" : "2020-07-10 13:14";
+            LocalDateTime localDateTime = i == 0
+                    ? LocalDateTime.of(2020, 12, 10, 13, 14)
+                    : LocalDateTime.of(2020, 07, 10, 13, 14);
+
+            ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, zoneId);
+            assertParsingResults(
+                    conf -> {
+                        conf.setDateTimeFormat("y-MM-dd HH:mm");
+                        conf.setTimeZone(timeZone);
+                    },
+                    stringToParse, localDateTime,
+                    stringToParse, zonedDateTime.toOffsetDateTime(),
+                    stringToParse, zonedDateTime,
+                    stringToParse, zonedDateTime.toInstant());
+
+            // TODO if zone is shown
+        }
+    }
+
+    @Test
+    public void testDateParsing() throws TemplateException, TemplateValueFormatException {
+        String stringToParse = "2020-11-10";
+        LocalDate localDate = LocalDate.of(2020, 11, 10);
+        assertParsingResults(
+                conf -> conf.setDateFormat("y-MM-dd"),
+                stringToParse, localDate);
+    }
+
+    @Test
+    public void testLocalTimeParsing() throws TemplateException, TemplateValueFormatException {
+        String stringToParse = "13:14";
+        assertParsingResults(
+                conf -> conf.setTimeFormat("HH:mm"),
+                stringToParse, LocalTime.of(13, 14));
+        // TODO if zone is shown
+    }
+
+    @Test
+    public void testParsingLocalization() throws TemplateException, TemplateValueFormatException {
+        // TODO
+    }
+
+    @Test
+    public void testOffsetTimeParsing() throws TemplateException, TemplateValueFormatException {
+        ZoneId zoneId = ZoneId.of("America/New_York");
+        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
+
+        assertParsingResults(
+                conf -> {
+                    conf.setTimeFormat("HH:mmXX");
+                    conf.setTimeZone(timeZone);
+                },
+                "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"));
+        }
+    }
+
     static private String formatTemporal(Consumer<Configurable> configurer, Temporal... values) throws
             TemplateException {
         Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
@@ -235,4 +296,69 @@ public class TemporalFormatTest {
 
         return sb.toString();
     }
+
+    static private void assertParsingResults(
+            Consumer<Configurable> configurer,
+            Object... stringsAndExpectedResults) throws TemplateException, TemplateValueFormatException {
+        Configuration conf = new Configuration(Configuration.VERSION_2_3_32);
+        conf.setTimeZone(DateUtil.UTC);
+        conf.setLocale(Locale.US);
+
+        configurer.accept(conf);
+
+        Environment env = null;
+        try {
+            env = new Template(null, "", conf).createProcessingEnvironment(null, null);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        if (stringsAndExpectedResults.length % 2 != 0) {
+            throw new IllegalArgumentException(
+                    "stringsAndExpectedResults.length must be even, but was " + stringsAndExpectedResults.length + ".");
+        }
+        for (int i = 0; i < stringsAndExpectedResults.length; i += 2) {
+            Object value = stringsAndExpectedResults[i];
+            if (!(value instanceof String)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + i + "] should be a String");
+            }
+            String string = (String) value;
+
+            value = stringsAndExpectedResults[i + 1];
+            if (!(value instanceof Temporal)) {
+                throw new IllegalArgumentException("stringsAndExpectedResults[" + (i + 1) + "] should be a Temporal");
+            }
+            Temporal expectedResult = (Temporal) value;
+
+            Class<? extends Temporal> temporalClass = expectedResult.getClass();
+            TemplateTemporalFormat templateTemporalFormat = env.getTemplateTemporalFormat(temporalClass);
+
+            Temporal actualResult;
+            {
+                Object actualResultObject = templateTemporalFormat.parse(string);
+                if (actualResultObject instanceof Temporal) {
+                    actualResult = (Temporal) actualResultObject;
+                } else if (actualResultObject instanceof TemplateTemporalModel) {
+                    actualResult = ((TemplateTemporalModel) actualResultObject).getAsTemporal();
+                } else {
+                    throw new AssertionError(
+                            "Parsing result of " + jQuote(string) + " is not of an expected type: "
+                                    + ClassUtil.getShortClassNameOfObject(actualResultObject));
+                }
+            }
+
+            if (!expectedResult.equals(actualResult)) {
+                throw new AssertionError(
+                        "Parsing result of " + jQuote(string) + " "
+                                + "(with temporalFormat[" + temporalClass.getSimpleName() + "]="
+                                + jQuote(env.getTemporalFormat(temporalClass)) + ", "
+                                + "timeZone=" + jQuote(env.getTimeZone().toZoneId()) + ", "
+                                + "locale=" + jQuote(env.getLocale()) + ") "
+                                + "differs from expected.\n"
+                                + "Expected: " + expectedResult + "\n"
+                                + "Actual:   " + actualResult);
+            }
+        }
+    }
+
 }
diff --git a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
index 05991b9..65c146e 100644
--- a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
+++ b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl
@@ -22,15 +22,14 @@
 <@assertEquals expected="Apr 5, 2003" actual=localDate?string />
 <@assertEquals expected="6:07:08 AM" actual=localTime?string />
 <@assertEquals expected="Apr 5, 2003 7:07:08 AM" actual=offsetDateTime?string />
-<@assertEquals expected="7:07:08 AM" actual=offsetTime?string />
+<@assertFails message="Daylight Saving">${offsetTime}</@>
 <@assertEquals expected="2003" actual=year?string />
 <@assertEquals expected="2003-04" actual=yearMonth?string />
 <@assertEquals expected="Apr 5, 2003 7:07:08 AM" actual=zonedDateTime?string />
 
 <#setting timeZone="America/New_York">
 <@assertEquals expected="6:07:08 AM" actual=localTime?string />
-<#-- Automatic medium->long step up: -->
-<@assertEquals expected="6:07:08 AM Z" actual=offsetTime?string />
+<@assertFails message="Daylight Saving">${offsetTime}</@>
 
 <@assertEquals expected="2003-04-05T01:07:08-05:00" actual=instant?string.iso />
 <@assertEquals expected="2003-04-05T06:07:08" actual=localDateTime?string.iso />
@@ -81,10 +80,8 @@
 <@assertEquals expected="5 avril 2003 06:07:08" actual=localDateTime?string.long />
 <@assertEquals expected="samedi 5 avril 2003 06:07:08" actual=localDateTime?string.full />
 
-<#-- Automatic short->medium->long step up: -->
-<@assertEquals expected="06:07:08 Z" actual=offsetTime?string.short />
-<#-- Automatic medium->long step up: -->
-<@assertEquals expected="06:07:08 Z" actual=offsetTime?string.medium />
+<@assertFails message="Daylight Saving">${offsetTime?string.short}</@>
+<@assertFails message="Daylight Saving">${offsetTime?string.medium}</@>
 <@assertEquals expected="06:07:08 Z" actual=offsetTime?string.long />
 <@assertEquals expected="06 h 07 Z" actual=offsetTime?string.full />