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 c395df5  [FREEMARKER-35] When using date_format, time_format, and 
datetime_format configuration settings for java.time Temporal-s (as opposed to 
java.util.Date-s), instead of using DateTimeFormatter.ofPattern(pattern) that 
only worked well in some cases, use our own SimpleDateFormat pattern parser, 
that generates a DateTimeFormatter that behaves similarly to a SimpleDateFormat.
c395df5 is described below

commit c395df5cd7dd453e0e975ee74625bd6b7182ddd6
Author: ddekany <[email protected]>
AuthorDate: Sun Dec 26 11:23:49 2021 +0100

    [FREEMARKER-35] When using date_format, time_format, and datetime_format 
configuration settings for java.time Temporal-s (as opposed to 
java.util.Date-s), instead of using DateTimeFormatter.ofPattern(pattern) that 
only worked well in some cases, use our own SimpleDateFormat pattern parser, 
that generates a DateTimeFormatter that behaves similarly to a SimpleDateFormat.
---
 src/main/java/freemarker/core/Environment.java     |   3 +
 .../core/JavaTemplateTemporalFormat.java           |   3 +-
 .../java/freemarker/template/utility/DateUtil.java | 316 ++++++++++++++++++++-
 .../utility/DateUtilsPatternParsingTest.java       | 303 ++++++++++++++++++++
 4 files changed, 623 insertions(+), 2 deletions(-)

diff --git a/src/main/java/freemarker/core/Environment.java 
b/src/main/java/freemarker/core/Environment.java
index 5a80b2a..2046396 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -29,6 +29,7 @@ import java.text.Collator;
 import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
 import java.text.NumberFormat;
+import java.time.ZoneId;
 import java.time.temporal.Temporal;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -136,6 +137,8 @@ public final class Environment extends Configurable {
 
     private TemplateNumberFormat cachedTemplateNumberFormat;
     private Map<String, TemplateNumberFormat> cachedTemplateNumberFormats;
+    private TimeZone cachedZoneIdTimeZone;
+    private ZoneId cachedZoneId;
 
     /**
      * Stores the date/time/date-time formatters that are used when no format 
is explicitly given at the place of
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 8bc49e9..47cd093 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -41,6 +41,7 @@ import java.util.regex.Pattern;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
 import freemarker.template.utility.ClassUtil;
+import freemarker.template.utility.DateUtil;
 import freemarker.template.utility.StringUtil;
 
 /**
@@ -115,7 +116,7 @@ class JavaTemplateTemporalFormat extends 
TemplateTemporalFormat {
             timePartFormatStyle = null;
 
             try {
-                dateTimeFormatter = DateTimeFormatter.ofPattern(formatString);
+                dateTimeFormatter = 
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(formatString, locale);
             } catch (IllegalArgumentException e) {
                 throw new InvalidFormatParametersException(e.getMessage(), e);
             }
diff --git a/src/main/java/freemarker/template/utility/DateUtil.java 
b/src/main/java/freemarker/template/utility/DateUtil.java
index a4db6e5..0a05db4 100644
--- a/src/main/java/freemarker/template/utility/DateUtil.java
+++ b/src/main/java/freemarker/template/utility/DateUtil.java
@@ -20,6 +20,16 @@
 package freemarker.template.utility;
 
 import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.chrono.Chronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DecimalStyle;
+import java.time.format.SignStyle;
+import java.time.format.TextStyle;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalField;
+import java.time.temporal.WeekFields;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.GregorianCalendar;
@@ -791,6 +801,8 @@ public class DateUtil {
         return TimeZone.getTimeZone(sb.toString());
     }
 
+
+
     private static int groupToMillisecond(String g)
             throws DateParseException {
         if (g == null) {
@@ -803,7 +815,309 @@ public class DateUtil {
         int i = groupToInt(g, "partial-seconds", 0, Integer.MAX_VALUE);
         return g.length() == 1 ? i * 100 : (g.length() == 2 ? i * 10 : i);
     }
-    
+
+    /**
+     * Creates a {@link DateTimeFormatter} from a pattern that uses the syntax 
that's used by the
+     * {@link SimpleDateFormat} constructor.
+     *
+     * @param pattern The pattern with {@link SimpleDateFormat} syntax.
+     * @param locale The locale of the output of the formatter
+     *
+     * @return
+     *
+     * @throws IllegalArgumentException If the pattern is not a valid {@link 
SimpleDateFormat} pattern (based on the
+     * syntax documented for Java 15).
+     */
+    public static DateTimeFormatter 
dateTimeFormatterFromSimpleDateFormatPattern(String pattern, Locale locale) {
+        return 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale)
+                .toFormatter(locale)
+                .withDecimalStyle(DecimalStyle.of(locale))
+                .withChronology(getChronologyForLocaleWithLegacyRules(locale));
+    }
+
+    private static DateTimeFormatterBuilder 
createDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale) {
+        DateTimeFormatterBuilder builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
false);
+        if (builder == null) {
+            builder = 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(pattern, locale, 
true);
+        }
+        return builder;
+    }
+
+    /**
+     * @param standaloneFormGuess Guess if we only will have one field.
+     * @return If {@code null}, then {@code standaloneFormGuess} was wrong, 
and it also mattered, so retry with the
+     *         inverse of it.
+     */
+    private static DateTimeFormatterBuilder 
tryCreateDateTimeFormatterBuilderFromSimpleDateFormatPattern(
+            String pattern, Locale locale, boolean standaloneFormGuess) {
+        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
+
+        builder.parseCaseInsensitive(); // Must be before pattern(s) appended!
+
+        int numberOfFields = 0;
+        int len = pattern.length();
+        int pos = 0;
+        int lastClosingQuotePos = Integer.MIN_VALUE;
+        boolean standaloneFormGuessWasUsed = false;
+        do {
+            char c = pos < len ? pattern.charAt(pos++) : 0;
+            if (isAsciiLetter(c)) {
+                int startPos = pos - 1;
+                while (pos < len && pattern.charAt(pos) == c) {
+                    pos++;
+                }
+                standaloneFormGuessWasUsed |= applyRepeatedLetter(
+                        c, pos - startPos, locale, pattern, 
standaloneFormGuess, builder);
+                numberOfFields++;
+            } else if (c == '\'') {
+                int literalStartPos = pos;
+                if (lastClosingQuotePos == literalStartPos - 2) {
+                    builder.appendLiteral('\'');
+                }
+                while (pos < len && pattern.charAt(pos) != '\'') {
+                    pos++;
+                }
+                if (literalStartPos == pos) {
+                    builder.appendLiteral('\'');
+                    // Doesn't set lastClosingQuotePos
+                } else {
+                    builder.appendLiteral(pattern.substring(literalStartPos, 
pos));
+                    lastClosingQuotePos = pos;
+                }
+                pos++; // Because char at pos was already processed
+            } else {
+                int literalStartPos = pos - 1;
+                while (pos < len && 
!isAsciiLetterOrApostrophe(pattern.charAt(pos))) {
+                    pos++;
+                }
+                builder.appendLiteral(pattern.substring(literalStartPos, pos));
+                // No pos++, because the char at pos is not yet processed
+            }
+        } while (pos < len);
+        if (standaloneFormGuessWasUsed && standaloneFormGuess != 
(numberOfFields == 1)) {
+            return null;
+        }
+        return builder;
+    }
+
+    private static boolean applyRepeatedLetter(
+            char c, int width, Locale locale, String pattern,
+            boolean standaloneField,
+            DateTimeFormatterBuilder builder) {
+        boolean standaloneFieldArgWasUsed = false;
+        switch (c) {
+            case 'y':
+                appendYearLike(width, ChronoField.YEAR_OF_ERA, builder);
+                break;
+            case 'Y':
+                appendYearLike(width, WeekFields.of(locale).weekBasedYear(), 
builder);
+                break;
+            case 'M':
+            case 'L':
+                if (width <= 2) {
+                    appendValueWithSafeWidth(ChronoField.MONTH_OF_YEAR, width, 
2, builder);
+                } else if (width == 3) {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? 
TextStyle.SHORT_STANDALONE : TextStyle.SHORT;
+                    } else {
+                        textStyle = TextStyle.SHORT_STANDALONE;
+                    }
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                } else {
+                    TextStyle textStyle;
+                    if (c == 'M') {
+                        standaloneFieldArgWasUsed = true;
+                        textStyle = standaloneField ? 
TextStyle.FULL_STANDALONE : TextStyle.FULL;
+                    } else {
+                        textStyle = TextStyle.FULL_STANDALONE;
+                    }
+                    builder.appendText(ChronoField.MONTH_OF_YEAR, textStyle);
+                }
+                break;
+            case 'd':
+                appendValueWithSafeWidth(ChronoField.DAY_OF_MONTH, width, 2, 
builder);
+                break;
+            case 'D':
+                if (width == 1) {
+                    builder.appendValue(ChronoField.DAY_OF_YEAR);
+                } else if (width == 2) {
+                    // 2 wide if possible, but don't lose a digit over 99. 
SimpleDateFormat does this too.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, 2, 3, 
SignStyle.NOT_NEGATIVE);
+                } else {
+                    // Here width is at least 3, so we are safe.
+                    builder.appendValue(ChronoField.DAY_OF_YEAR, width);
+                }
+                break;
+            case 'h':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_AMPM, 
width, 2, builder);
+                break;
+            case 'H':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_DAY, width, 2, 
builder);
+                break;
+            case 'k':
+                appendValueWithSafeWidth(ChronoField.CLOCK_HOUR_OF_DAY, width, 
2, builder);
+                break;
+            case 'K':
+                appendValueWithSafeWidth(ChronoField.HOUR_OF_AMPM, width, 2, 
builder);
+                break;
+            case 'a':
+                // From experimentation with SimpleDataFormat it seemed that 
the number of repetitions doesn't matter.
+                builder.appendText(ChronoField.AMPM_OF_DAY, TextStyle.SHORT);
+                break;
+            case 'm':
+                appendValueWithSafeWidth(ChronoField.MINUTE_OF_HOUR, width, 2, 
builder);
+                break;
+            case 's':
+                appendValueWithSafeWidth(ChronoField.SECOND_OF_MINUTE, width, 
2, builder);
+                break;
+            case 'S':
+                // This is quite dangerous, like "s.SS" gives misleading 
output, but SimpleDateFormat does this.
+                appendValueWithSafeWidth(ChronoField.MILLI_OF_SECOND, width, 
3, builder);
+                break;
+            case 'u':
+                builder.appendValue(ChronoField.DAY_OF_WEEK, width);
+                break;
+            case 'w':
+                
appendValueWithSafeWidth(WeekFields.of(locale).weekOfWeekBasedYear(), width, 2, 
builder);
+                break;
+            case 'W':
+                appendValueWithSafeWidth(WeekFields.of(locale).weekOfMonth(), 
width, 1, builder);
+                break;
+            case 'E':
+                if (width <= 3 ) {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.SHORT);
+                } else {
+                    builder.appendText(ChronoField.DAY_OF_WEEK, 
TextStyle.FULL);
+                }
+                break;
+            case 'G':
+                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that. (It's not always a perfect
+                // match though, like japanese calendar era "Reiwa" VS "R".)
+                builder.appendText(ChronoField.ERA, TextStyle.SHORT);
+                break;
+            case 'F':
+                // While SimpleDateFormat documentation says it's "day of week 
in month", the actual output is "aligned
+                // week of month" (a bug, I assume). With DateTimeFormatter 
"F" is "aligned day of week in month", but
+                // our goal here is to mimic the behaviour of SimpleDateFormat.
+                appendValueWithSafeWidth(ChronoField.ALIGNED_WEEK_OF_MONTH, 
width, 1, builder);
+                break;
+            case 'z':
+                if (width < 4) {
+                    builder.appendZoneText(TextStyle.SHORT);
+                } else {
+                    builder.appendZoneText(TextStyle.FULL);
+                }
+                break;
+            case 'Z':
+                // Width apparently doesn't matter for SimpleDateFormat, and 
we mimic that.
+                builder.appendOffset("+HHMM","+0000");
+                break;
+            case 'X':
+                if (width == 1) {
+                    // We lose the minutes here, just like SimpleDateFormat 
did.
+                    builder.appendOffset("+HH", "Z");
+                } else if (width == 2) {
+                    builder.appendOffset("+HHMM", "Z");
+                } else if (width == 3) {
+                    builder.appendOffset("+HH:MM", "Z");
+                } else {
+                    throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
+                            + StringUtil.jQuote(pattern) + ": "
+                            + " \"X\" width in SimpleDateFormat patterns must 
be less than 4.");
+                }
+                break;
+            default:
+                throw new IllegalArgumentException("Can't create 
DateTimeFormatter from SimpleDateFormat pattern "
+                        + StringUtil.jQuote(pattern) + ": "
+                        + StringUtil.jQuote(c) + " is an invalid or 
unsupported SimpleDateFormat pattern letter.");
+        }
+        return standaloneFieldArgWasUsed;
+    }
+
+    private static void appendYearLike(int width, TemporalField field, 
DateTimeFormatterBuilder builder) {
+        if (width != 2) {
+            builder.appendValue(field, width, 19, SignStyle.NORMAL);
+        } else {
+            builder.appendValueReduced(field, 2, 2, 2000);
+        }
+    }
+
+    private static String repeatChar(char c, int count) {
+        char[] chars = new char[count];
+        for (int i = 0; i < count; i++) {
+            chars[i] = c;
+        }
+        return new String(chars);
+    }
+
+    /**
+     * Used for non-negative numerical fields, behaves like {@link 
SimpleDateFormat} regarding the field width.
+     *
+     * @param width The width specified in the pattern
+     * @param safeWidth The minimum width needed to safely display any valid 
value
+     */
+    private static void appendValueWithSafeWidth(
+            TemporalField field, int width, int safeWidth, 
DateTimeFormatterBuilder builder) {
+        builder.appendValue(field, width, width < safeWidth ? safeWidth : 
width, SignStyle.NOT_NEGATIVE);
+    }
+
+    private static boolean isAsciiLetterOrApostrophe(char c) {
+        return isAsciiLetter(c) || c == '\'';
+    }
+
+    private static boolean isAsciiLetter(char c) {
+        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
+    }
+
+    /**
+     * Gives the {@link Chronology} for a {@link Locale} that {@link 
Calendar#getInstance(Locale)} would; except, that
+     * returned a {@link Calendar} instead of a {@link Chronology}, so this is 
somewhat complicated to do.
+     */
+    private static Chronology getChronologyForLocaleWithLegacyRules(Locale 
locale) {
+        // Usually null
+        String askedCalendarType = locale.getUnicodeLocaleType("ca");
+
+        Calendar calendar = Calendar.getInstance(locale);
+
+        Locale chronologyLocale;
+        String legacyLocalizedCalendarType = calendar.getCalendarType();
+        // The pre-java.time API gives different localized defaults sometimes, 
or at least for th_TH. To be on the safe
+        // side, for the two non-gregory types that pre-java.time Java 
supported out-of-the-box, we force the calendar
+        // type in the Locale, for which later we will ask the Chronology.
+        if (("buddhist".equals(legacyLocalizedCalendarType) || 
"japanese".equals(legacyLocalizedCalendarType))
+                && !legacyLocalizedCalendarType.equals(askedCalendarType)) {
+            chronologyLocale = createLocaleWithCalendarType(
+                    locale,
+                    
legacyCalendarTypeToJavaTimeApiCompatibleName(legacyLocalizedCalendarType));
+        } else {
+            // Even if there's no difference in the default chronology of the 
locale, the calendar type names that
+            // worked with the legacy API might not be recognized by the 
java.time API.
+            String compatibleAskedCalendarType = 
legacyCalendarTypeToJavaTimeApiCompatibleName(askedCalendarType);
+            if (askedCalendarType != compatibleAskedCalendarType) { // 
deliberately doesn't use equals(...)
+                chronologyLocale = createLocaleWithCalendarType(locale, 
compatibleAskedCalendarType);
+            } else {
+                chronologyLocale = locale;
+            }
+        }
+        Chronology chronology = Chronology.ofLocale(chronologyLocale);
+        return chronology;
+    }
+
+    private static String legacyCalendarTypeToJavaTimeApiCompatibleName(String 
legacyType) {
+        // "gregory" is the Calendar.calendarType in the old API. The closest 
Chronology.ofLocale recognizes is "ISO".
+        return "gregory".equals(legacyType) ? "ISO" : legacyType;
+    }
+
+    private static Locale createLocaleWithCalendarType(Locale locale, String 
legacyApiCalendarType) {
+        return new Locale.Builder()
+                .setLocale(locale)
+                .setUnicodeLocaleKeyword("ca", legacyApiCalendarType)
+                .build();
+    }
+
     /**
      * Used internally by {@link DateUtil}; don't use its implementations for
      * anything else.
diff --git 
a/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java 
b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
new file mode 100644
index 0000000..a010f9f
--- /dev/null
+++ b/src/test/java/freemarker/template/utility/DateUtilsPatternParsingTest.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.template.utility;
+
+import static org.junit.Assert.*;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+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.temporal.Temporal;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.apache.commons.lang.StringUtils;
+import org.hamcrest.Matchers;
+import org.junit.Test;
+
+/**
+ * Move pattern parsing related tests from {@link DateUtilTest} to here.
+ */
+public class DateUtilsPatternParsingTest {
+    private static final ZoneId SAMPLE_ZONE_ID = ZoneId.of("America/New_York");
+    private static final ZoneId UTC_ZONE_ID = ZoneId.of("UTC");
+    private static final ZonedDateTime SAMPLE_ZDT
+            = ZonedDateTime.of(2021, 12, 25, 13, 30, 55, 534200000, 
SAMPLE_ZONE_ID);
+    private static final ZonedDateTime[] SAMPLE_ZDTS = new ZonedDateTime[] {
+            SAMPLE_ZDT,
+            ZonedDateTime.of(2009, 8, 7, 6, 5, 4, 3, UTC_ZONE_ID),
+            ZonedDateTime.of(2010, 8, 7, 6, 5, 4, 300000000, 
ZoneId.ofOffset("GMT", ZoneOffset.ofHoursMinutes(10, 30))),
+            ZonedDateTime.of(2011, 8, 7, 6, 5, 4, 30000000, 
ZoneId.ofOffset("GMT", ZoneOffset.ofHoursMinutes(-10, -30))),
+            ZonedDateTime.of(2012, 8, 7, 6, 5, 4, 3000000, 
ZoneId.ofOffset("GMT", ZoneOffset.ofHours(1))),
+            ZonedDateTime.of(2013, 8, 7, 6, 5, 4, 300000, 
ZoneId.ofOffset("GMT", ZoneOffset.ofHours(-1))),
+            ZonedDateTime.of(1995, 2, 28, 1, 30, 55, 0, SAMPLE_ZONE_ID),
+            ZonedDateTime.of(12345, 1, 1, 0, 0, 0, 0, UTC_ZONE_ID),
+    };
+
+    // Most likely supported on different test systems
+    private static final Locale SAMPLE_LOCALE = Locale.US;
+
+    private static final Locale[] SAMPLE_LOCALES = new Locale[] {
+            // Locales picked more or less arbitrarily, in alphabetical order
+            Locale.CHINA,
+            new Locale("ar", "EG"),
+            new Locale("fi", "FI"),
+            Locale.GERMAN,
+            new Locale("hi", "IN"),
+            Locale.JAPANESE,
+            new Locale("ru", "RU"),
+            Locale.US,
+            new Locale("th", "TH") // Uses buddhist calendar
+    };
+
+    @Test
+    public void testBasics() {
+        for (String pattern : new String[] {
+                "yyyy-MM-dd HH:mm:ss.SSS",
+                "'Date:' yy MMM d (E), 'Time:' hh:mm a, 'Zone:' z"}) {
+            for (ZonedDateTime zdt : SAMPLE_ZDTS) {
+                assertSDFAndDTFOutputsEqual(pattern, zdt, SAMPLE_LOCALE);
+            }
+        }
+    }
+
+    @Test
+    public void testAllLettersAndWidths() {
+        for (String letter : new String[] {
+                "G", "y", "Y", "M", "L", "w", "W", "D", "d", "F", "E", "u", 
"a", "H", "k", "K", "h", "m", "s", "S",
+                "z", "Z", "X"}) {
+            for (int width = 1; width <= 6; width++) {
+                if (letter.equals("X") && width > 3) {
+                    // Not supported by SimpleDateFormat.
+                    continue;
+                }
+                String pattern = StringUtils.repeat(letter, width);
+                for (ZonedDateTime zdt : SAMPLE_ZDTS) {
+                    for (Locale locale : SAMPLE_LOCALES) {
+                        assertSDFAndDTFOutputsEqual(pattern, zdt, locale);
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testEscaping() {
+        assertSDFAndDTFOutputsEqual("''", SAMPLE_ZDT, SAMPLE_LOCALE);
+        assertSDFAndDTFOutputsEqual("''''", SAMPLE_ZDT, SAMPLE_LOCALE);
+        assertSDFAndDTFOutputsEqual("'v'y'v'", SAMPLE_ZDT, SAMPLE_LOCALE);
+        assertSDFAndDTFOutputsEqual("'v''v'", SAMPLE_ZDT, SAMPLE_LOCALE);
+        assertSDFAndDTFOutputsEqual("'v''''v'", SAMPLE_ZDT, SAMPLE_LOCALE);
+    }
+
+    @Test
+    public void testWeekBasedNumericalFields() {
+        // SDF always starts the week with Monday (numerical value 1), 
regardless of Locale.
+        ZonedDateTime zdt = SAMPLE_ZDT;
+        for (int i = 0; i < 1000; i++) {
+            for (Locale locale : SAMPLE_LOCALES) {
+                assertSDFAndDTFOutputsEqual("w", zdt, locale);
+                assertSDFAndDTFOutputsEqual("W", zdt, locale);
+                assertSDFAndDTFOutputsEqual("u", zdt, locale);
+            }
+            zdt = zdt.plusDays(1);
+        }
+    }
+
+
+    @Test
+    public void testStandaloneOrNot() {
+        for (Locale locale : SAMPLE_LOCALES) {
+            assertSDFAndDTFOutputsEqual("MMM", SAMPLE_ZDT, locale);
+            assertSDFAndDTFOutputsEqual("y MMM", SAMPLE_ZDT, locale);
+            assertSDFAndDTFOutputsEqual("MMMM", SAMPLE_ZDT, locale);
+            assertSDFAndDTFOutputsEqual("y MMMM", SAMPLE_ZDT, locale);
+        }
+    }
+
+    @Test
+    public void testCalendars() {
+        Locale baseLocale = new Locale("th", "TH");
+        for (String forcedCalendarType : new String[] {null, "buddhist", 
"japanese", "gregory"}) {
+            Locale locale = forcedCalendarType != null
+                    ? new Locale.Builder()
+                            .setLocale(baseLocale)
+                            .setUnicodeLocaleKeyword("ca", forcedCalendarType)
+                            .build()
+                    : baseLocale;
+            assertSDFAndDTFOutputsEqual("y M d", SAMPLE_ZDT, locale);
+        }
+    }
+
+    @Test
+    public void testHistoricalDate() throws ParseException {
+        LocalDate temporal = LocalDate.of(-123, 4, 5);
+
+        // There are historical calendar changes in play that depend on 
locale. j.u did take those into account, but
+        // java.time doesn't anymore, and we can't realistically fix that. So 
we will limit the locales we test for to
+        // some that used Julian calendar till 1528, and then Gregorian 
calendar.
+
+        // We can't convert the LocalDate to Date as usual, because j.t 
assumes Gregorian calendar even before 1528.
+        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("y M d", 
Locale.US);
+        TimeZone timeZone = TimeZone.getTimeZone(UTC_ZONE_ID);
+        simpleDateFormat.setTimeZone(timeZone);
+        Date date = simpleDateFormat.parse("-123 4 5"); // Interpreted with 
Julian calendar
+
+        for (Locale locale : new Locale[] {Locale.US, Locale.GERMAN}) {
+            assertSDFAndDTFOutputsEqual("y M d G", date, timeZone, temporal, 
locale);
+        }
+    }
+
+    @Test
+    public void testInvalidPatternExceptions() {
+        try {
+            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("y v", 
SAMPLE_LOCALE);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), Matchers.containsString("\"v\""));
+        }
+
+        try {
+            DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("XXXX", 
SAMPLE_LOCALE);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), Matchers.containsString("4"));
+        }
+    }
+
+    @Test
+    public void testParsingBasics() throws ParseException {
+        assertEquals(
+                LocalDateTime.of(2021, 12, 23, 1, 2, 3),
+                LocalDateTime.from(
+                        
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern("yyyyMMddHHmmss", 
SAMPLE_LOCALE)
+                                .parse("20211223010203")));
+    }
+
+    @Test
+    public void testParsingWidthRestrictions() throws ParseException {
+        // Year allows more digits than specified:
+        assertLocalDateParsing(
+                LocalDate.of(12021, 2, 3),
+                "yyyyMMdd", "120210203", SAMPLE_LOCALE);
+        assertLocalDateParsing(
+                LocalDate.of(321, 2, 3),
+                "yMMdd", "3210203", SAMPLE_LOCALE);
+
+        // But not less:
+        assertLocalDateParsingFails("yyyyMMdd", "3210203", SAMPLE_LOCALE);
+        // SimpleDateFormat is more lenient here:
+        new SimpleDateFormat("yyyyMMdd", SAMPLE_LOCALE).parse("3210203");
+        // But being strict is certainly a safer, so we don't mimic 
SimpleDateFormat behavior in this case.
+
+        // Year has arbitrary 0 padding, month and day on has that up to 2 
digits:
+        assertLocalDateParsing(
+                LocalDate.of(2021, 1, 2),
+                "y-M-d", "2021-1-2", SAMPLE_LOCALE);
+        assertLocalDateParsing(
+                LocalDate.of(2021, 10, 20),
+                "y-M-d", "2021-10-20", SAMPLE_LOCALE);
+        assertLocalDateParsing(
+                LocalDate.of(2021, 1, 2),
+                "y-M-d", "02021-01-02", SAMPLE_LOCALE);
+
+        assertLocalDateParsingFails("y-M-d", "2021-010-20", SAMPLE_LOCALE);
+        assertLocalDateParsingFails("y-M-d", "2021-10-020", SAMPLE_LOCALE);
+    }
+
+    @Test
+    public void testParsingCaseInsensitive() throws ParseException {
+        assertLocalDateParsing(
+                LocalDate.of(2021, 1, 2),
+                "y-MMM-d", "2021-jAn-02", SAMPLE_LOCALE);
+        // SimpleDateFormat is case-insensitive too:
+        new SimpleDateFormat("y-MMM-d", SAMPLE_LOCALE).parse("2021-jAn-02");
+    }
+
+    @Test
+    public void testParsingLocale() throws ParseException {
+        assertLocalDateParsing(
+                LocalDate.of(2021, 1, 12),
+                "y-MMM-d", "2021-\u044F\u043D\u0432-12", new Locale("ru", 
"RU"));
+        assertLocalDateParsing(
+                LocalDate.of(2021, 1, 12),
+                "y-MMM-d", 
"\u0968\u0966\u0968\u0967-\u091C\u0928\u0935\u0930\u0940-\u0967\u0968",
+                new Locale("hi", "IN"));
+    }
+
+    private void assertSDFAndDTFOutputsEqual(String pattern, ZonedDateTime 
zdt, Locale locale) {
+        assertSDFAndDTFOutputsEqual(pattern, Date.from(zdt.toInstant()), 
TimeZone.getTimeZone(zdt.getZone()), zdt, locale);
+    }
+
+    private void assertSDFAndDTFOutputsEqual(String pattern, Date date, 
TimeZone timeZone, Temporal temporal, Locale locale) {
+        SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale);
+        sdf.setTimeZone(timeZone);
+
+        DateTimeFormatter dtf = 
DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, locale);
+
+        String sdfOutput = sdf.format(date);
+        String dtfOutput = dtf.format(temporal);
+        if (!sdfOutput.equals(dtfOutput)) {
+            fail("Output of\n"
+                    + "SDF(" + StringUtil.jQuote(pattern) + ", " + 
date.toInstant().atZone(timeZone.toZoneId()) + "), and\n"
+                    + "DTF(" + dtf + ", " + temporal + ") differs, with locale 
" + locale + ":\n"
+                    + "SDF: " + sdfOutput + "\n"
+                    + "DTF: " + dtfOutput);
+        }
+    }
+
+    private void assertLocalDateParsing(LocalDate temporal, String pattern, 
String string, Locale locale) {
+        try {
+            assertEquals(
+                    temporal,
+                    parseLocalDate(pattern, string, locale));
+        } catch (DateTimeParseException e) {
+            throw new AssertionError(
+                "Failed to parse " + StringUtil.jQuote(string)
+                        + " with pattern " + StringUtil.jQuote(pattern)
+                        + " and locale " + locale + ".",
+                    e);
+        }
+    }
+
+    private void assertLocalDateParsingFails(String pattern, String string, 
Locale locale) {
+        try {
+            parseLocalDate(pattern, string, locale);
+            fail("Parsing was expected to fail for: "
+                    + StringUtil.jQuote(pattern) + ", " + 
StringUtil.jQuote(string) + ", " + locale);
+        } catch (DateTimeParseException e) {
+            // Expected
+        }
+    }
+
+    private LocalDate parseLocalDate(String pattern, String string, Locale 
locale) {
+        return LocalDate.from(
+                DateUtil.dateTimeFormatterFromSimpleDateFormatPattern(pattern, 
locale)
+                        .parse(string));
+    }
+
+}

Reply via email to