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