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));
+ }
+
+}