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 7f6e528006c34a82f1b79481209cb8ea2439d101 Author: ddekany <[email protected]> AuthorDate: Sun Jun 7 21:23:18 2020 +0200 FREEMARKER-35: Reworked things so that it will allow format caching and custom formatters later. Basically TemporalUtils was sliced up to TemplateTemporalFormat-s and their factories, and to some Environment code. Fixed many rough edges along the way, and discovered even more (added TODO comments for them). --- ...seJavaTemplateTemporalFormatTemplateFormat.java | 64 ++++++ .../freemarker/core/BuiltInsForMultipleTypes.java | 11 +- src/main/java/freemarker/core/Configurable.java | 16 +- src/main/java/freemarker/core/Environment.java | 254 ++++++++++++++++----- src/main/java/freemarker/core/EvalUtil.java | 12 +- .../core/ISOLikeTemplateTemporalFormat.java | 47 ++++ .../core/ISOTemplateTemporalFormatFactory.java | 118 ++++++++++ .../core/JavaTemplateTemporalFormat.java | 129 +++++++++++ .../core/JavaTemplateTemporalFormatFactory.java | 42 ++++ .../freemarker/core/TemplateTemporalFormat.java | 44 ++-- .../core/TemplateTemporalFormatFactory.java | 81 +++++++ ...at.java => ToStringTemplateTemporalFormat.java} | 46 ++-- .../ToStringTemplateTemporalFormatFactory.java | 42 ++++ .../core/UnformattableTemporalTypeException.java | 38 +++ .../core/XSTemplateTemporalFormatFactory.java | 124 ++++++++++ .../java/freemarker/core/_CoreTemporalUtils.java | 40 +++- src/main/java/freemarker/core/_MessageUtil.java | 4 +- .../freemarker/template/utility/TemporalUtil.java | 197 ---------------- ...igurableTest.java => CoreTemporalUtilTest.java} | 26 ++- .../freemarker/core/TemporalErrorMessagesTest.java | 55 +++++ .../test/templatesuite/TemplateTestCase.java | 2 + .../test/templatesuite/templates/temporal.ftl | 4 + 22 files changed, 1070 insertions(+), 326 deletions(-) diff --git a/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java new file mode 100644 index 0000000..a7e7412 --- /dev/null +++ b/src/main/java/freemarker/core/BaseJavaTemplateTemporalFormatTemplateFormat.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.Temporal; + +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateTemporalModel; + +abstract class BaseJavaTemplateTemporalFormatTemplateFormat extends TemplateTemporalFormat { + private final DateTimeFormatter dateTimeFormatterWithZoneOverride; + + protected BaseJavaTemplateTemporalFormatTemplateFormat(DateTimeFormatter dateTimeFormatterWithZoneOverride) { + this.dateTimeFormatterWithZoneOverride = dateTimeFormatterWithZoneOverride; + } + + @Override + public String format(TemplateTemporalModel tm) + throws TemplateValueFormatException, TemplateModelException { + try { + DateTimeFormatter dateTimeFormatter = this.dateTimeFormatterWithZoneOverride; + Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm); + + // TODO [FREEMARKER-35] Doing these on runtime is wasteful if it's know if for which format setting + // this object is used for. + if (temporal instanceof Instant) { + temporal = ((Instant) temporal).atZone(dateTimeFormatter.getZone()); + } else if (temporal instanceof OffsetDateTime) { + dateTimeFormatter = dateTimeFormatter.withZone(((OffsetDateTime) temporal).getOffset()); + } else if (temporal instanceof OffsetTime) { + dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset()); + } else if (temporal instanceof ZonedDateTime) { + dateTimeFormatter = dateTimeFormatter.withZone(null); + } + + return dateTimeFormatter.format(temporal); + } catch (DateTimeException e) { + throw new UnformattableValueException(e.getMessage(), e); + } + } +} diff --git a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java index 1159d5d..8cc6942 100644 --- a/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java +++ b/src/main/java/freemarker/core/BuiltInsForMultipleTypes.java @@ -606,7 +606,6 @@ class BuiltInsForMultipleTypes { } } - private class TemporalFormatter implements TemplateScalarModel, TemplateHashModel, TemplateMethodModel { private final TemplateTemporalModel temporalModel; private final Environment env; @@ -616,7 +615,7 @@ class BuiltInsForMultipleTypes { TemporalFormatter(TemplateTemporalModel temporalModel, Environment env) throws TemplateException { this.temporalModel = temporalModel; this.env = env; - this.defaultFormat = env.getTemplateTemporalFormat(temporalModel.getAsTemporal().getClass()); + this.defaultFormat = env.getTemplateTemporalFormat(temporalModel, target, false); } @Override @@ -633,12 +632,10 @@ class BuiltInsForMultipleTypes { private TemplateModel formatWith(String key) throws TemplateModelException { try { - return new SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target)); + return new SimpleScalar(env.formatTemporalToPlainText(temporalModel, key, target, stringBI.this, true)); } catch (TemplateException e) { // `e` should always be a TemplateModelException here, but to be sure: throw _CoreAPI.ensureIsTemplateModelException("Failed to format value", e); - } catch (TemplateValueFormatException e) { - throw new _TemplateModelException("Failed to format value", e); } } @@ -652,7 +649,7 @@ class BuiltInsForMultipleTypes { cachedValue = EvalUtil.assertFormatResultNotNull(defaultFormat.format(temporalModel)); } catch (TemplateValueFormatException e) { try { - throw _MessageUtil.newCantFormatDateException(defaultFormat, target, e, true); + throw _MessageUtil.newCantFormatTemporalException(defaultFormat, target, e, true); } catch (TemplateException e2) { // `e` should always be a TemplateModelException here, but to be sure: throw _CoreAPI.ensureIsTemplateModelException("Failed to format date/time/datetime", e2); @@ -688,7 +685,7 @@ class BuiltInsForMultipleTypes { this.defaultFormat = dateType == TemplateDateModel.UNKNOWN ? null // Lazy unknown type error in getAsString() : env.getTemplateDateFormat( - dateType, EvalUtil.modelToDate(dateModel, target).getClass(), target, true); + dateType, EvalUtil.modelToDate(dateModel, target).getClass(), target, false); } @Override diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java index c891510..d3c8646 100644 --- a/src/main/java/freemarker/core/Configurable.java +++ b/src/main/java/freemarker/core/Configurable.java @@ -46,6 +46,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Properties; import java.util.Set; import java.util.TimeZone; @@ -1414,11 +1415,12 @@ public class Configurable { * @return Never {@code null}, maybe {@code ""} though. * * @throws NullPointerException If {@link temporalClass} was {@code null} - * @throws IllegalArgumentException If {@link temporalClass} was not a supported {@link Temporal} subclass. + * @throws IllegalArgumentException If {@link temporalClass} is not a supported {@link Temporal} subclass. * * @since 2.3.31 */ public String getTemporalFormat(Class<? extends Temporal> temporalClass) { + Objects.requireNonNull(temporalClass); if (temporalClass == Instant.class) { return getInstantFormat(); } else if (temporalClass == LocalDate.class) { @@ -1524,7 +1526,17 @@ public class Configurable { } return parent != null ? parent.getCustomDateFormat(name) : null; } - + + /** + * Gets the custom name format registered for the name. + * + * @since 2.3.31 + */ + public TemplateTemporalFormatFactory getCustomTemporalFormat(String name) { + // TODO [FREEMARKER-35] + return null; + } + /** * Sets the exception handler used to handle exceptions occurring inside templates. * The default is {@link TemplateExceptionHandler#DEBUG_HANDLER}. The recommended values are: diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java index 3a0a251..0e57db4 100644 --- a/src/main/java/freemarker/core/Environment.java +++ b/src/main/java/freemarker/core/Environment.java @@ -1637,13 +1637,7 @@ public final class Environment extends Configurable { final String name; final String params; { - int endIdx; - findParamsStart: for (endIdx = 1; endIdx < formatStringLen; endIdx++) { - char c = formatString.charAt(endIdx); - if (c == ' ' || c == '_') { - break findParamsStart; - } - } + int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen); name = formatString.substring(1, endIdx); params = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : ""; } @@ -1729,62 +1723,37 @@ public final class Environment extends Configurable { } /** - * @param tdmSourceExpr + * @param blamedTdmSourceExpr * The blamed expression if an error occurs; only used for error messages. */ - String formatDateToPlainText(TemplateDateModel tdm, Expression tdmSourceExpr, + String formatDateToPlainText(TemplateDateModel tdm, Expression blamedTdmSourceExpr, boolean useTempModelExc) throws TemplateException { - TemplateDateFormat format = getTemplateDateFormat(tdm, tdmSourceExpr, useTempModelExc); - + TemplateDateFormat format = getTemplateDateFormat(tdm, blamedTdmSourceExpr, useTempModelExc); try { return EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm)); } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(format, tdmSourceExpr, e, useTempModelExc); + throw _MessageUtil.newCantFormatDateException(format, blamedTdmSourceExpr, e, useTempModelExc); } } /** - * @param blamedDateSourceExp + * @param blamedTdmSourceExp * The blamed expression if an error occurs; only used for error messages. * @param blamedFormatterExp * The blamed expression if an error occurs; only used for error messages. */ String formatDateToPlainText(TemplateDateModel tdm, String formatString, - Expression blamedDateSourceExp, Expression blamedFormatterExp, + Expression blamedTdmSourceExp, Expression blamedFormatterExp, boolean useTempModelExc) throws TemplateException { - Date date = EvalUtil.modelToDate(tdm, blamedDateSourceExp); - TemplateDateFormat format = getTemplateDateFormat( - formatString, tdm.getDateType(), date.getClass(), - blamedDateSourceExp, blamedFormatterExp, + formatString, tdm.getDateType(), EvalUtil.modelToDate(tdm, blamedTdmSourceExp).getClass(), + blamedTdmSourceExp, blamedFormatterExp, useTempModelExc); try { return EvalUtil.assertFormatResultNotNull(format.formatToPlainText(tdm)); } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(format, blamedDateSourceExp, e, useTempModelExc); - } - } - - /** - * @param blamedDateSourceExp - * The blamed expression if an error occurs; only used for error messages. - */ - String formatTemporalToPlainText(TemplateTemporalModel ttm, String formatString, Expression blamedDateSourceExp) throws TemplateException, TemplateValueFormatException { - TemplateTemporalFormat ttf = getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), formatString, true); - try { - return EvalUtil.assertFormatResultNotNull(ttf.format(ttm)); - } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(ttf, blamedDateSourceExp, e, true); - } - } - - String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression tdmSourceExpr) throws TemplateException { - TemplateTemporalFormat ttf = getTemplateTemporalFormat(ttm.getAsTemporal().getClass()); - try { - return EvalUtil.assertFormatResultNotNull(ttf.format(ttm)); - } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(ttf, tdmSourceExpr, e, false); + throw _MessageUtil.newCantFormatDateException(format, blamedTdmSourceExp, e, useTempModelExc); } } @@ -1963,7 +1932,8 @@ public final class Environment extends Configurable { } /** - * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to {@link TemplateException}-s. + * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative + * {@link TemplateException}-s. */ TemplateDateFormat getTemplateDateFormat( int dateType, Class<? extends Date> dateClass, Expression blamedDateSourceExp, boolean useTempModelExc) @@ -2003,7 +1973,7 @@ public final class Environment extends Configurable { } /** - * Same as {@link #getTemplateDateFormat(String, int, Class)}, but translates the exceptions to + * Same as {@link #getTemplateDateFormat(String, int, Class)}, but translates the exceptions to more informative * {@link TemplateException}-s. */ TemplateDateFormat getTemplateDateFormat( @@ -2164,13 +2134,7 @@ public final class Environment extends Configurable { && Character.isLetter(formatString.charAt(1))) { final String name; { - int endIdx; - findParamsStart: for (endIdx = 1; endIdx < formatStringLen; endIdx++) { - char c = formatString.charAt(endIdx); - if (c == ' ' || c == '_') { - break findParamsStart; - } - } + int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen); name = formatString.substring(1, endIdx); formatParams = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : ""; } @@ -2219,14 +2183,192 @@ public final class Environment extends Configurable { + (sqlDTTZ ? CACHED_TDFS_SQL_D_T_TZ_OFFS : 0); } - TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass) { - // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by temporalFormat (instantFormat, localDateFormat, etc.), locale and timeZone change. - return getTemplateTemporalFormat(temporalClass, getTemporalFormat(temporalClass), true); + /** + * @param blamedTtmSourceExp + * The blamed expression if an error occurs; only used for error messages. + */ + String formatTemporalToPlainText(TemplateTemporalModel ttm, String formatString, + Expression blamedTtmSourceExp, Expression blamedFormatterSourceExp, + boolean useTempModelExc) + throws TemplateException { + TemplateTemporalFormat ttf = getTemplateTemporalFormat( + formatString, ttm, + blamedTtmSourceExp, blamedFormatterSourceExp, + useTempModelExc); + try { + return EvalUtil.assertFormatResultNotNull(ttf.format(ttm)); + } catch (TemplateValueFormatException e) { + throw _MessageUtil.newCantFormatTemporalException(ttf, blamedTtmSourceExp, e, true); + } + } + + String formatTemporalToPlainText(TemplateTemporalModel ttm, Expression blamedTtmSourceExp, + boolean useTempModelExc) throws TemplateException { + TemplateTemporalFormat ttf = getTemplateTemporalFormat( + ttm, blamedTtmSourceExp, useTempModelExc); + try { + return EvalUtil.assertFormatResultNotNull(ttf.format(ttm)); + } catch (TemplateValueFormatException e) { + throw _MessageUtil.newCantFormatTemporalException(ttf, blamedTtmSourceExp, e, false); + } + } + + /** + * Convenience overload of {@link #getTemplateTemporalFormat(Class, Expression, boolean)}. + */ + TemplateTemporalFormat getTemplateTemporalFormat( + TemplateTemporalModel ttm, Expression blamedTemporalSourceExp, boolean useTempModelExc) + throws TemplateException { + return getTemplateTemporalFormat( + EvalUtil.modelToTemporal( + ttm, blamedTemporalSourceExp).getClass(), blamedTemporalSourceExp, useTempModelExc); + } + + /** + * Same as {@link #getTemplateDateFormat(int, Class)}, but translates the exceptions to moer informative + * {@link TemplateException}-s. + */ + TemplateTemporalFormat getTemplateTemporalFormat( + Class<? extends Temporal> temporalClass, Expression blamedTemporalSourceExp, boolean useTempModelExc) + throws TemplateException { + try { + return getTemplateTemporalFormat(temporalClass); + } catch (TemplateValueFormatException e) { + String settingName; + String settingValue; + try { + settingName = _CoreTemporalUtils.temporalClassToFormatSettingName(temporalClass); + settingValue = getTemporalFormat(temporalClass); + } catch (IllegalArgumentException e2) { + settingName = "???"; + settingValue = "???"; + } + + _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder( + "The value of the \"", settingName, + "\" FreeMarker configuration setting is a malformed temporal format string: ", + new _DelayedJQuote(settingValue), ". Reason given: ", + e.getMessage()); + throw useTempModelExc ? new _TemplateModelException(e, desc) : new _MiscTemplateException(e, desc); + } } - private TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass, String format, boolean cache) { - // TODO [FREEMARKER-35] format keyed cache, invalidated by local and timeZone change. - return new TemplateTemporalFormat(format, getLocale(), getTimeZone()); + TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass) + throws TemplateValueFormatException { + // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by temporalFormat (instantFormat, localDateFormat, etc.), locale, and timeZone change. + return getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + } + + /** + * Convenience overload of {@link #getTemplateTemporalFormat(String, Class, Expression, Expression, boolean)}. + */ + TemplateTemporalFormat getTemplateTemporalFormat( + String formatString, TemplateTemporalModel ttm, + Expression blamedTemporalSourceExp, Expression blamedFormatterExp, + boolean useTempModelExc) + throws TemplateException { + return getTemplateTemporalFormat( + formatString, EvalUtil.modelToTemporal(ttm, blamedFormatterExp).getClass(), + blamedTemporalSourceExp, blamedFormatterExp, + useTempModelExc); + } + + /** + * Same as {@link #getTemplateTemporalFormat(String, Class)}, but translates the exceptions to more informative + * {@link TemplateException}-s. + */ + TemplateTemporalFormat getTemplateTemporalFormat( + String formatString, Class<? extends Temporal> temporalClass, + Expression blamedTemporalSourceExp, Expression blamedFormatterExp, + boolean useTempModelExc) + throws TemplateException { + try { + return getTemplateTemporalFormat(formatString, temporalClass); + } catch (TemplateValueFormatException e) { + _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder( + "Can't create temporal format based on format string ", + new _DelayedJQuote(formatString), ". Reason given: ", + e.getMessage()) + .blame(blamedFormatterExp); + throw useTempModelExc ? new _TemplateModelException(e, desc) : new _MiscTemplateException(e, desc); + } + } + + private TemplateTemporalFormat getTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass) + throws TemplateValueFormatException { + // TODO [FREEMARKER-35] format keyed cache, invalidated by locale, and timeZone change. + return getTemplateTemporalFormatWithoutCache(formatString, temporalClass, getLocale(), getTimeZone()); + } + + /** + * Returns the {@link TemplateTemporalFormat} for the given parameters without using the {@link Environment}-level + * cache. Of course, the {@link TemplateTemporalFormatFactory} involved might still uses its own cache, which can be + * global (class-loader-level) or {@link Environment}-level. + * + * @param formatString + * See the similar parameter of {@link TemplateTemporalFormatFactory#get} + * @param dateType + * See the similar parameter of {@link TemplateTemporalFormatFactory#get} + * @param zonelessInput + * See the similar parameter of {@link TemplateTemporalFormatFactory#get} + */ + private TemplateTemporalFormat getTemplateTemporalFormatWithoutCache( + String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) + throws TemplateValueFormatException { + final int formatStringLen = formatString.length(); + final String formatParams; + + TemplateTemporalFormatFactory formatFactory; + char firstChar = formatStringLen != 0 ? formatString.charAt(0) : 0; + + if (firstChar == 'x' + && formatStringLen > 1 + && formatString.charAt(1) == 's') { + formatFactory = XSTemplateTemporalFormatFactory.INSTANCE; + formatParams = formatString.substring(2); + } else if (firstChar == 'i' + && formatStringLen > 2 + && formatString.charAt(1) == 's' + && formatString.charAt(2) == 'o') { + formatFactory = ISOTemplateTemporalFormatFactory.INSTANCE; + formatParams = formatString.substring(3); + } else if (firstChar == '@' + && formatStringLen > 1 + && Character.isLetter(formatString.charAt(1))) { + final String name; + { + int endIdx = getCustomFormatStringNameEnd(formatString, formatStringLen); + name = formatString.substring(1, endIdx); + formatParams = endIdx < formatStringLen ? formatString.substring(endIdx + 1) : ""; + } + + formatFactory = getCustomTemporalFormat(name); + if (formatFactory == null) { + throw new UndefinedCustomFormatException( + "No custom temporal format was defined with name " + StringUtil.jQuote(name)); + } + } else if (formatStringLen == 0) { + // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did + formatParams = formatString; + formatFactory = ToStringTemplateTemporalFormatFactory.INSTANCE; + } else { + formatParams = formatString; + formatFactory = JavaTemplateTemporalFormatFactory.INSTANCE; + } + + return formatFactory.get(formatParams, temporalClass, locale, timeZone, this); + } + + private static int getCustomFormatStringNameEnd(String formatString, int formatStringLen) { + int endIdx; + findParamsStart: + for (endIdx = 1; endIdx < formatStringLen; endIdx++) { + char c = formatString.charAt(endIdx); + if (c == ' ' || c == '_') { + break findParamsStart; + } + } + return endIdx; } /** diff --git a/src/main/java/freemarker/core/EvalUtil.java b/src/main/java/freemarker/core/EvalUtil.java index 50f32c5..78d626a 100644 --- a/src/main/java/freemarker/core/EvalUtil.java +++ b/src/main/java/freemarker/core/EvalUtil.java @@ -409,11 +409,11 @@ class EvalUtil { } } else if (tm instanceof TemplateTemporalModel) { TemplateTemporalModel ttm = (TemplateTemporalModel) tm; - TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass()); + TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass(), exp, false); try { return assertFormatResultNotNull(format.format(ttm)); } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(format, exp, e, false); + throw _MessageUtil.newCantFormatTemporalException(format, exp, e, false); } } else if (tm instanceof TemplateMarkupOutputModel) { return tm; @@ -424,7 +424,7 @@ class EvalUtil { /** * Like {@link #coerceModelToStringOrMarkup(TemplateModel, Expression, String, Environment)}, but gives error - * if the result is markup. This is what you normally use where markup results can't be used. + * if the result is markup. This is what you normally used where markup results can't be used. * * @param seqTip * Tip to display if the value type is not coercable, but it's sequence or collection. @@ -452,11 +452,11 @@ class EvalUtil { } } else if (tm instanceof TemplateTemporalModel) { TemplateTemporalModel ttm = (TemplateTemporalModel) tm; - TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm.getAsTemporal().getClass()); + TemplateTemporalFormat format = env.getTemplateTemporalFormat(ttm, exp, false); try { return ensureFormatResultString(format.format(ttm), exp, env); } catch (TemplateValueFormatException e) { - throw _MessageUtil.newCantFormatDateException(format, exp, e, false); + throw _MessageUtil.newCantFormatTemporalException(format, exp, e, false); } } else { return coerceModelToTextualCommon(tm, exp, seqTip, false, false, env); @@ -480,7 +480,7 @@ class EvalUtil { } else if (tm instanceof TemplateDateModel) { return assertFormatResultNotNull(env.formatDateToPlainText((TemplateDateModel) tm, exp, false)); } else if (tm instanceof TemplateTemporalModel) { - return assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) tm, exp)); + return assertFormatResultNotNull(env.formatTemporalToPlainText((TemplateTemporalModel) tm, exp, false)); } else { return coerceModelToTextualCommon(tm, exp, seqTip, false, false, env); } diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java new file mode 100644 index 0000000..8a8f8ff --- /dev/null +++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalFormat.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.format.DateTimeFormatter; + +// TODO [FREEMARKER-35] These should support parameters similar to {@link ISOTemplateDateFormat}, +final class ISOLikeTemplateTemporalFormat extends BaseJavaTemplateTemporalFormatTemplateFormat { + private final String description; + + public ISOLikeTemplateTemporalFormat(DateTimeFormatter dateTimeFormatter, String description) { + super(dateTimeFormatter); + this.description = description; + } + + @Override + public boolean isLocaleBound() { + return false; + } + + @Override + public boolean isTimeZoneBound() { + return true; + } + + @Override + public String getDescription() { + return description; + } +} diff --git a/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java new file mode 100644 index 0000000..58e47c0 --- /dev/null +++ b/src/main/java/freemarker/core/ISOTemplateTemporalFormatFactory.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.LocalTime; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; + +class ISOTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory { + + static final ISOTemplateTemporalFormatFactory INSTANCE = new ISOTemplateTemporalFormatFactory(); + + private static final DateTimeFormatter ISO8601_DATE_TIME_FORMAT = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .optionalStart() + .appendLiteral('T') + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(":") + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(":") + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) + .optionalStart() + .appendOffsetId() + .optionalEnd() + .optionalEnd() + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + private static final DateTimeFormatter ISO8601_TIME_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(":") + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(":") + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + private static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR) + .appendLiteral("-") + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + static final DateTimeFormatter ISO8601_YEAR_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR) + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + @Override + public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws + TemplateValueFormatException { + if (!params.isEmpty()) { + // TODO [FREEMARKER-35] + throw new InvalidFormatParametersException("xs currently doesn't support parameters"); + } + + return getXSFormatter(temporalClass, timeZone.toZoneId()); + } + + private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? extends Temporal> temporalClass, ZoneId timeZone) { + final DateTimeFormatter dateTimeFormatter; + final String description; + if (temporalClass == LocalTime.class) { + dateTimeFormatter = ISO8601_TIME_FORMAT; + description = "ISO 8601 (subset) time"; + } else if (temporalClass == Year.class) { + dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO + description = "ISO 8601 (subset) year"; + } else if (temporalClass == YearMonth.class) { + dateTimeFormatter = ISO8601_YEARMONTH_FORMAT; + description = "ISO 8601 (subset) year-month"; + } else { + Class<? extends Temporal> normTemporalClass = + _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass); + if (normTemporalClass != temporalClass) { + return getXSFormatter(normTemporalClass, timeZone); + } else { + dateTimeFormatter = ISO8601_DATE_TIME_FORMAT; + description = "ISO 8601 (subset) date-time"; + } + } + // TODO [FREEMARKER-35] What about date-only? + return new ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), description); + } + +} diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java new file mode 100644 index 0000000..001b870 --- /dev/null +++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package freemarker.core; + +import java.time.Year; +import java.time.YearMonth; +import java.time.chrono.IsoChronology; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.FormatStyle; +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Pattern; + +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateTemporalModel; + +final class JavaTemplateTemporalFormat extends BaseJavaTemplateTemporalFormatTemplateFormat { + private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$"); + + // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did + private static final DateTimeFormatter SHORT_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); + private static final DateTimeFormatter MEDIUM_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); + private static final DateTimeFormatter LONG_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG); + private static final DateTimeFormatter FULL_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL); + + private final String formatString; + + JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) + throws InvalidFormatParametersException { + super(getDateTimeFormat(formatString, temporalClass, locale, timeZone)); + this.formatString = formatString; + } + + private static DateTimeFormatter getDateTimeFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) throws + InvalidFormatParametersException { + DateTimeFormatter result; + if (FORMAT_STYLE_PATTERN.matcher(formatString).matches()) { + // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did + boolean isYear = Year.class.isAssignableFrom(temporalClass); + boolean isYearMonth = YearMonth.class.isAssignableFrom(temporalClass); + String[] formatSplt = formatString.split("_"); + if (isYear || isYearMonth) { + String reducedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()), null, IsoChronology.INSTANCE, locale); + if (isYear) + result = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false)); + else + result = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true)); + } else if ("short".equals(formatString)) + result = SHORT_FORMAT; + else if ("medium".equals(formatString)) + result = MEDIUM_FORMAT; + else if ("long".equals(formatString)) + result = LONG_FORMAT; + else if ("full".equals(formatString)) + result = FULL_FORMAT; + else + result = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()), FormatStyle.valueOf(formatSplt[1].toUpperCase())); + } else { + try { + result = DateTimeFormatter.ofPattern(formatString); + } catch (IllegalArgumentException e) { + throw new InvalidFormatParametersException(e.getMessage(), e); + } + } + return result.withLocale(locale).withZone(timeZone.toZoneId()); + } + + // TODO [FREEMARKER-35] This override should be unecessary. Move logic here into getDateTimeFormat somehow. + @Override + public String format(TemplateTemporalModel tm) throws TemplateValueFormatException, TemplateModelException { + return super.format(tm); + } + + @Override + public String getDescription() { + return formatString; + } + + /** + * Tells if this formatter should be re-created if the locale changes. + */ + @Override + public boolean isLocaleBound() { + return true; + } + + /** + * Tells if this formatter should be re-created if the time zone changes. + */ + @Override + public boolean isTimeZoneBound() { + return true; + } + + // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did + private static String removeNonYM(String pattern, boolean withMonth) { + boolean separator = false; + boolean copy = true; + StringBuilder newPattern = new StringBuilder(); + for (char c : pattern.toCharArray()) { + if (c == '\'') + separator = !separator; + if (!separator && Character.isAlphabetic(c)) + copy = c == 'y' || c == 'u' || (withMonth && (c == 'M' || c == 'L')); + if (copy) + newPattern.append(c); + } + return newPattern.toString(); + } + +} diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java new file mode 100644 index 0000000..5e92a89 --- /dev/null +++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormatFactory.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Pattern; + +class JavaTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory { + public static final JavaTemplateTemporalFormatFactory INSTANCE = new JavaTemplateTemporalFormatFactory(); + + private JavaTemplateTemporalFormatFactory() { + // Not instantiated from outside + } + + @Override + public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, + Environment env) throws TemplateValueFormatException { + return new JavaTemplateTemporalFormat(params, temporalClass, locale, timeZone); + } + +} diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java index aec3844..9fa51ef 100644 --- a/src/main/java/freemarker/core/TemplateTemporalFormat.java +++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java @@ -18,45 +18,35 @@ */ package freemarker.core; -import java.util.Locale; -import java.util.TimeZone; +import java.time.format.DateTimeFormatter; import freemarker.template.TemplateModelException; import freemarker.template.TemplateTemporalModel; -import freemarker.template.utility.TemporalUtil; -public class TemplateTemporalFormat extends TemplateValueFormat { - private final String format; - private final Locale locale; - private final TimeZone timeZone; - - public TemplateTemporalFormat(String format, Locale locale, TimeZone timeZone) { - this.format = format; - this.locale = locale; - this.timeZone = timeZone; - } - - public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException { - return TemporalUtil.format(temporalModel.getAsTemporal(), format, locale, timeZone); - } +/** + * Represents a {@link Temporal} format; used in templates for formatting and parsing with that format. This is + * similar to Java's {@link DateTimeFormatter}, but made to fit the requirements of FreeMarker. Also, it makes easier to + * define formats that can't be represented with {@link DateTimeFormatter}. + * + * <p> + * Implementations need not be thread-safe if the {@link TemplateTemporalFormatFactory} doesn't recycle them among + * different {@link Environment}-s. As far as FreeMarker's concerned, instances are bound to a single + * {@link Environment}, and {@link Environment}-s are thread-local objects. + * + * @since 2.3.31 + */ +public abstract class TemplateTemporalFormat extends TemplateValueFormat { - @Override - public String getDescription() { - return format + " " + locale.toString(); - } + public abstract String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException; /** * Tells if this formatter should be re-created if the locale changes. */ - public boolean isLocaleBound() { - return true; - } + public abstract boolean isLocaleBound(); /** * Tells if this formatter should be re-created if the time zone changes. */ - public boolean isTimeZoneBound() { - return true; - } + public abstract boolean isTimeZoneBound(); } diff --git a/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java new file mode 100644 index 0000000..ae3971b --- /dev/null +++ b/src/main/java/freemarker/core/TemplateTemporalFormatFactory.java @@ -0,0 +1,81 @@ +/* + * 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.text.SimpleDateFormat; +import java.time.temporal.Temporal; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import freemarker.template.Configuration; +import freemarker.template.TemplateDateModel; + +/** + * Factory for a certain kind of {@link Temporal} formatting ({@link TemplateTemporalFormat}). Usually a singleton + * (one-per-VM or one-per-{@link Configuration}), and so must be thread-safe. + * + * TODO [FREEMARKER-35] @see Configurable#setCustomTemporalFormats(java.util.Map) + * + * @since 2.3.24 + */ +public abstract class TemplateTemporalFormatFactory extends TemplateValueFormatFactory { + + /** + * Returns a formatter for the given parameters. + * + * <p> + * The returned formatter can be a new instance or a reused (cached) instance. Note that {@link Environment} itself + * caches the returned instances, though that cache is lost with the {@link Environment} (i.e., when the top-level + * template execution ends), also it might flushes lot of entries if the locale or time zone is changed during + * template execution. So caching on the factory level is still useful, unless creating the formatters is + * sufficiently cheap. + * + * @param params + * The string that further describes how the format should look. For example, when the + * {@link Configurable#getInstantFormat()} ()} instantFormat} is {@code "@fooBar 1, 2"}, then it will be + * {@code "1, 2"} (and {@code "@fooBar"} selects the factory). The format of this string is up to the + * {@link TemplateTemporalFormatFactory} implementation. Not {@code null}, often an empty string. + * @param temporalClass + * The type of the temporal. If this type is not supported, the method should throw an + * {@link UnformattableTemporalTypeException} exception. + * @param locale + * The locale to format for. Not {@code null}. The resulting format should be bound to this locale + * forever (i.e. locale changes in the {@link Environment} must not be followed). + * @param timeZone + * The time zone to format for. Not {@code null}. The resulting format must be bound to this time zone + * forever (i.e. time zone changes in the {@link Environment} must not be followed). + * @param env + * The runtime environment from which the formatting was called. This is mostly meant to be used for + * {@link Environment#setCustomState(Object, Object)}/{@link Environment#getCustomState(Object)}. The + * result shouldn't depend on setting values in the {@link Environment}, as changing other setting + * will not necessarily invalidate the result. + * + * @throws TemplateValueFormatException + * If any problem occurs while parsing/getting the format. Notable subclasses: + * {@link InvalidFormatParametersException} if {@code params} is malformed; + * {@link UnformattableTemporalTypeException} if the {@code temporalClass} subclass is + * not supported by this factory. + */ + public abstract TemplateTemporalFormat get( + String params, + Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) + throws TemplateValueFormatException; + +} diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java similarity index 59% copy from src/main/java/freemarker/core/TemplateTemporalFormat.java copy to src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java index aec3844..3bd6d64 100644 --- a/src/main/java/freemarker/core/TemplateTemporalFormat.java +++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormat.java @@ -16,47 +16,49 @@ * specific language governing permissions and limitations * under the License. */ + package freemarker.core; -import java.util.Locale; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.Temporal; +import java.util.Objects; import java.util.TimeZone; import freemarker.template.TemplateModelException; import freemarker.template.TemplateTemporalModel; -import freemarker.template.utility.TemporalUtil; -public class TemplateTemporalFormat extends TemplateValueFormat { - private final String format; - private final Locale locale; - private final TimeZone timeZone; +class ToStringTemplateTemporalFormat extends TemplateTemporalFormat { - public TemplateTemporalFormat(String format, Locale locale, TimeZone timeZone) { - this.format = format; - this.locale = locale; - this.timeZone = timeZone; - } + private final ZoneId timeZone; - public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, TemplateModelException { - return TemporalUtil.format(temporalModel.getAsTemporal(), format, locale, timeZone); + ToStringTemplateTemporalFormat(TimeZone timeZone) { + this.timeZone = timeZone.toZoneId(); } @Override - public String getDescription() { - return format + " " + locale.toString(); + public String format(TemplateTemporalModel temporalModel) throws TemplateValueFormatException, + TemplateModelException { + Temporal temporal = TemplateFormatUtil.getNonNullTemporal(temporalModel); + // TODO [FREEMARKER-35] This is not right, but for now we mimic what TemporalUtils did + if (temporal instanceof Instant) { + temporal = ((Instant) temporal).atZone(timeZone); + } + return temporal.toString(); } - /** - * Tells if this formatter should be re-created if the locale changes. - */ + @Override public boolean isLocaleBound() { - return true; + return false; } - /** - * Tells if this formatter should be re-created if the time zone changes. - */ + @Override public boolean isTimeZoneBound() { return true; } + @Override + public String getDescription() { + return "toString()"; + } } diff --git a/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java new file mode 100644 index 0000000..951471e --- /dev/null +++ b/src/main/java/freemarker/core/ToStringTemplateTemporalFormatFactory.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; + +class ToStringTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory { + + static final ToStringTemplateTemporalFormatFactory INSTANCE = new ToStringTemplateTemporalFormatFactory(); + + private ToStringTemplateTemporalFormatFactory() { + // Not meant to be called from outside + } + + @Override + public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws + TemplateValueFormatException { + if (!params.isEmpty()) { + throw new InvalidFormatParametersException("toString format doesn't support parameters"); + } + return new ToStringTemplateTemporalFormat(timeZone); + } +} diff --git a/src/main/java/freemarker/core/UnformattableTemporalTypeException.java b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java new file mode 100644 index 0000000..a4f377a --- /dev/null +++ b/src/main/java/freemarker/core/UnformattableTemporalTypeException.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.temporal.Temporal; + +import freemarker.template.TemplateTemporalModel; + +/** + * Thrown when a {@link TemplateTemporalModel} can't be formatted because the {@link TemplateTemporalFormatFactory} + * doesn't support it. + * + * @since 2.3.31 + */ +public final class UnformattableTemporalTypeException extends UnformattableValueException { + + public UnformattableTemporalTypeException(Class<? extends Temporal> temporalClass) { + super("Temporal type not supported: " + temporalClass.getName()); + } + +} diff --git a/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java new file mode 100644 index 0000000..1f36313 --- /dev/null +++ b/src/main/java/freemarker/core/XSTemplateTemporalFormatFactory.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import static freemarker.core.ISOTemplateTemporalFormatFactory.*; + +import java.time.LocalTime; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; + +class XSTemplateTemporalFormatFactory extends TemplateTemporalFormatFactory { + + static final XSTemplateTemporalFormatFactory INSTANCE = new XSTemplateTemporalFormatFactory(); + + private XSTemplateTemporalFormatFactory() { + // Not meant to be called from outside + } + + private final static DateTimeFormatter XSD_DATE_TIME_FORMAT = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .optionalStart() + .appendLiteral('T') + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(":") + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(":") + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) + .optionalEnd() + .optionalStart() + .appendOffsetId() + .optionalEnd() + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + private final static DateTimeFormatter XSD_TIME_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(":") + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(":") + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) + .optionalStart() + .appendOffsetId() + .optionalEnd() + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + private static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR) + .appendLiteral("-") + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .optionalStart() + .appendOffsetId() + .optionalEnd() + .toFormatter() + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + @Override + public TemplateTemporalFormat get(String params, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone, Environment env) throws + TemplateValueFormatException { + if (!params.isEmpty()) { + // TODO [FREEMARKER-35] + throw new InvalidFormatParametersException("xs currently doesn't support parameters"); + } + + return getXSFormatter(temporalClass, timeZone.toZoneId()); + } + + private static ISOLikeTemplateTemporalFormat getXSFormatter(Class<? extends Temporal> temporalClass, ZoneId timeZone) { + final DateTimeFormatter dateTimeFormatter; + final String description; + if (temporalClass == LocalTime.class) { + dateTimeFormatter = XSD_TIME_FORMAT; + description = "W3C XML Schema time"; + } else if (temporalClass == Year.class) { + dateTimeFormatter = ISO8601_YEAR_FORMAT; // Same as ISO + description = "W3C XML Schema year"; + } else if (temporalClass == YearMonth.class) { + dateTimeFormatter = XSD_YEARMONTH_FORMAT; + description = "W3C XML Schema year-month"; + } else { + Class<? extends Temporal> normTemporalClass = + _CoreTemporalUtils.normalizeSupportedTemporalClass(temporalClass); + if (normTemporalClass != temporalClass) { + return getXSFormatter(normTemporalClass, timeZone); + } else { + dateTimeFormatter = XSD_DATE_TIME_FORMAT; + description = "W3C XML Schema date-time"; + } + } + // TODO [FREEMARKER-35] What about date-only? + return new ISOLikeTemplateTemporalFormat(dateTimeFormatter.withZone(timeZone), description); + } + +} diff --git a/src/main/java/freemarker/core/_CoreTemporalUtils.java b/src/main/java/freemarker/core/_CoreTemporalUtils.java index 027c13d..f95906c 100644 --- a/src/main/java/freemarker/core/_CoreTemporalUtils.java +++ b/src/main/java/freemarker/core/_CoreTemporalUtils.java @@ -28,12 +28,15 @@ import java.time.OffsetDateTime; import java.time.OffsetTime; import java.time.Year; import java.time.YearMonth; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.Temporal; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; +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 @@ -85,15 +88,44 @@ public class _CoreTemporalUtils { return OffsetDateTime.class; } else if (OffsetTime.class.isAssignableFrom(temporalClass)) { return OffsetTime.class; - } else if (Year.class.isAssignableFrom(temporalClass)) { - return Year.class; - } else if (YearMonth.class.isAssignableFrom(temporalClass)) { - return YearMonth.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) { + return Configuration.INSTANT_FORMAT_KEY; + } else if (temporalClass == LocalDate.class) { + return Configuration.LOCAL_DATE_FORMAT_KEY; + } else if (temporalClass == LocalDateTime.class) { + return Configuration.LOCAL_DATE_TIME_FORMAT_KEY; + } else if (temporalClass == LocalTime.class) { + return Configuration.LOCAL_TIME_FORMAT_KEY; + } else if (temporalClass == OffsetDateTime.class) { + return Configuration.OFFSET_DATE_TIME_FORMAT_KEY; + } else if (temporalClass == OffsetTime.class) { + return Configuration.OFFSET_TIME_FORMAT_KEY; + } else if (temporalClass == ZonedDateTime.class) { + return Configuration.ZONED_DATE_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/core/_MessageUtil.java b/src/main/java/freemarker/core/_MessageUtil.java index 7867f66..034ec0f 100644 --- a/src/main/java/freemarker/core/_MessageUtil.java +++ b/src/main/java/freemarker/core/_MessageUtil.java @@ -322,10 +322,10 @@ public class _MessageUtil { : new _MiscTemplateException(e, null, desc); } - public static TemplateException newCantFormatDateException(TemplateTemporalFormat format, Expression dataSrcExp, + public static TemplateException newCantFormatTemporalException(TemplateTemporalFormat format, Expression dataSrcExp, TemplateValueFormatException e, boolean useTempModelExc) { _ErrorDescriptionBuilder desc = new _ErrorDescriptionBuilder( - "Failed to format date/time/datetime with format ", new _DelayedJQuote(format.getDescription()), ": ", + "Failed to format temporal value with format ", new _DelayedJQuote(format.getDescription()), ": ", e.getMessage()) .blame(dataSrcExp); return useTempModelExc diff --git a/src/main/java/freemarker/template/utility/TemporalUtil.java b/src/main/java/freemarker/template/utility/TemporalUtil.java deleted file mode 100644 index a1c9d73..0000000 --- a/src/main/java/freemarker/template/utility/TemporalUtil.java +++ /dev/null @@ -1,197 +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.template.utility; - -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.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.chrono.IsoChronology; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeFormatterBuilder; -import java.time.format.FormatStyle; -import java.time.temporal.ChronoField; -import java.time.temporal.Temporal; -import java.util.Locale; -import java.util.TimeZone; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -public class TemporalUtil { - private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile("^(short|medium|long|full)(_(short|medium|long|full))?$"); - private final static DateTimeFormatter SHORT_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); - private final static DateTimeFormatter MEDIUM_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); - private final static DateTimeFormatter LONG_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG); - private final static DateTimeFormatter FULL_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL); - - private final static DateTimeFormatter XSD_FORMAT = new DateTimeFormatterBuilder() - .append(DateTimeFormatter.ISO_LOCAL_DATE) - .optionalStart() - .appendLiteral('T') - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(":") - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(":") - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) - .optionalEnd() - .optionalStart() - .appendOffsetId() - .optionalEnd() - .toFormatter(); - private final static DateTimeFormatter XSD_TIME_FORMAT = new DateTimeFormatterBuilder() - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(":") - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(":") - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) - .optionalStart() - .appendOffsetId() - .optionalEnd() - .toFormatter(); - public static final DateTimeFormatter XSD_YEARMONTH_FORMAT = new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR) - .appendLiteral("-") - .appendValue(ChronoField.MONTH_OF_YEAR, 2) - .optionalStart() - .appendOffsetId() - .optionalEnd() - .toFormatter(); - - public static final DateTimeFormatter ISO8601_FORMAT = new DateTimeFormatterBuilder() - .append(DateTimeFormatter.ISO_LOCAL_DATE) - .optionalStart() - .appendLiteral('T') - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(":") - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(":") - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) - .optionalStart() - .appendOffsetId() - .optionalEnd() - .optionalEnd() - .toFormatter(); - public static final DateTimeFormatter ISO8601_TIME_FORMAT = new DateTimeFormatterBuilder() - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(":") - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(":") - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) - .toFormatter(); - public static final DateTimeFormatter ISO8601_YEARMONTH_FORMAT = new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR) - .appendLiteral("-") - .appendValue(ChronoField.MONTH_OF_YEAR, 2) - .toFormatter(); - public static final DateTimeFormatter ISO8601_YEAR_FORMAT = new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR) - .toFormatter(); - - private static DateTimeFormatter getISO8601Formatter(Temporal temporal) { - if (temporal instanceof LocalTime) - return ISO8601_TIME_FORMAT; - else if (temporal instanceof Year) - return ISO8601_YEAR_FORMAT; - else if (temporal instanceof YearMonth) - return ISO8601_YEARMONTH_FORMAT; - else - return ISO8601_FORMAT; - } - - private static DateTimeFormatter getXSFormatter(Temporal temporal) { - if (temporal instanceof LocalTime) - return XSD_TIME_FORMAT; - else if (temporal instanceof Year) - return ISO8601_YEAR_FORMAT;//ISO same as XSD here - else if (temporal instanceof YearMonth) - return XSD_YEARMONTH_FORMAT; - else - return XSD_FORMAT; - } - - public static String format(Temporal temporal, String format, Locale locale, TimeZone timeZone) { - //TODO: cache these DateTimeFormatter instances (withLocale & withZone create new instances too, when they differ from the instance) - if (temporal instanceof Instant) - temporal = ((Instant) temporal).atZone(timeZone == null ? ZoneOffset.UTC : timeZone.toZoneId()); - - DateTimeFormatter dtf; - if ("xs".equals(format)) - dtf = getXSFormatter(temporal); - else if ("iso".equals(format)) - dtf = getISO8601Formatter(temporal); - else if (FORMAT_STYLE_PATTERN.matcher(format).matches()) { - boolean isYear = temporal instanceof Year; - boolean isYearMonth = temporal instanceof YearMonth; - String[] formatSplt = format.split("_"); - if (isYear || isYearMonth) { - String reducedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.valueOf(formatSplt[0].toUpperCase()), null, IsoChronology.INSTANCE, locale); - if (isYear) - dtf = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, false)); - else - dtf = DateTimeFormatter.ofPattern(removeNonYM(reducedPattern, true)); - } else if ("short".equals(format)) - dtf = SHORT_FORMAT; - else if ("medium".equals(format)) - dtf = MEDIUM_FORMAT; - else if ("long".equals(format)) - dtf = LONG_FORMAT; - else if ("full".equals(format)) - dtf = FULL_FORMAT; - else - dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.valueOf(formatSplt[0].toUpperCase()), FormatStyle.valueOf(formatSplt[1].toUpperCase())); - } else if (!"".equals(format)) - dtf = DateTimeFormatter.ofPattern(format); - else - return temporal.toString(); - - dtf = dtf.withLocale(locale); - if (temporal instanceof OffsetDateTime) - dtf = dtf.withZone(((OffsetDateTime) temporal).getOffset()); - else if (!(temporal instanceof ZonedDateTime)) - dtf = dtf.withZone(timeZone.toZoneId()); - return dtf.format(temporal); - } - - private static String removeNonYM(String pattern, boolean withMonth) { - boolean separator = false; - boolean copy = true; - StringBuilder newPattern = new StringBuilder(); - for (char c : pattern.toCharArray()) { - if (c == '\'') - separator = !separator; - if (!separator && Character.isAlphabetic(c)) - copy = c == 'y' || c == 'u' || (withMonth && (c == 'M' || c == 'L')); - if (copy) - newPattern.append(c); - } - return newPattern.toString(); - } - -} diff --git a/src/test/java/freemarker/core/TemporalConfigurableTest.java b/src/test/java/freemarker/core/CoreTemporalUtilTest.java similarity index 67% rename from src/test/java/freemarker/core/TemporalConfigurableTest.java rename to src/test/java/freemarker/core/CoreTemporalUtilTest.java index 92cb575..2eab1f5 100644 --- a/src/test/java/freemarker/core/TemporalConfigurableTest.java +++ b/src/test/java/freemarker/core/CoreTemporalUtilTest.java @@ -21,15 +21,16 @@ package freemarker.core; import static org.junit.Assert.*; -import java.time.Instant; import java.time.chrono.ChronoLocalDate; import java.time.temporal.Temporal; +import java.util.HashSet; +import java.util.Set; import org.junit.Test; import freemarker.template.Configuration; -public class TemporalConfigurableTest { +public class CoreTemporalUtilTest { @Test public void testSupportedTemporalClassAreFinal() { @@ -42,12 +43,31 @@ public class TemporalConfigurableTest { @Test public void testGetTemporalFormat() { Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); + for (Class<? extends Temporal> supportedTemporalClass : _CoreTemporalUtils.SUPPORTED_TEMPORAL_CLASSES) { assertNotNull(cfg.getTemporalFormat(supportedTemporalClass)); } try { - assertNotNull(cfg.getTemporalFormat(ChronoLocalDate.class)); + cfg.getTemporalFormat(ChronoLocalDate.class); + fail(); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + 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) { + assertTrue(uniqueSettingNames.add(_CoreTemporalUtils.temporalClassToFormatSettingName(supportedTemporalClass))); + } + assertTrue(uniqueSettingNames.stream().allMatch(it -> cfg.getSettingNames(false).contains(it))); + + try { + _CoreTemporalUtils.temporalClassToFormatSettingName(ChronoLocalDate.class); fail(); } catch (IllegalArgumentException e) { // Expected diff --git a/src/test/java/freemarker/core/TemporalErrorMessagesTest.java b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java new file mode 100644 index 0000000..e9c2791 --- /dev/null +++ b/src/test/java/freemarker/core/TemporalErrorMessagesTest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import java.time.Instant; +import java.time.LocalTime; + +import org.junit.Test; + +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +public class TemporalErrorMessagesTest extends TemplateTest { + + @Test + public void testExplicitFormatString() throws TemplateException { + addToDataModel("t", LocalTime.now()); + assertErrorContains("${t?string('yyyy-HH')}", "Failed to format temporal value", "yyyy-HH", "YearOfEra"); + } + + @Test + public void testDefaultFormatStringBadFormatString() throws TemplateException { + getConfiguration().setSetting("local_time_format", "ABCDEF"); + addToDataModel("t", LocalTime.now()); + assertErrorContains("${t}", "local_time_format", "ABCDEF"); + assertErrorContains("${t?string}", "local_time_format", "ABCDEF"); + } + + @Test + public void testDefaultFormatStringIncompatibleFormatString() throws TemplateException { + getConfiguration().setSetting("local_time_format", "yyyy-HH"); + addToDataModel("t", LocalTime.now()); + // TODO [FREEMARKER-35] Should contain "local_time_format" too + assertErrorContains("${t}", "Failed to format temporal value", "yyyy-HH", "YearOfEra"); + assertErrorContains("${t?string}", "Failed to format temporal value", "yyyy-HH", "YearOfEra"); + } + +} diff --git a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java index 4522026..1ab62ab 100644 --- a/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java +++ b/src/test/java/freemarker/test/templatesuite/TemplateTestCase.java @@ -27,6 +27,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.time.OffsetTime; import java.time.Year; import java.time.YearMonth; import java.time.ZoneId; @@ -365,6 +366,7 @@ public class TemplateTestCase extends FileTestCase { dataModel.put("yearMonth", YearMonth.from(ldt)); ZonedDateTime zdt = ldt.atZone(ZoneId.of("UTC")); dataModel.put("offsetDateTime", zdt.toOffsetDateTime()); + dataModel.put("offsetTime", zdt.toOffsetDateTime().toOffsetTime()); dataModel.put("zonedDateTime", zdt); } else if (simpleTestName.equals("var-layers")) { dataModel.put("x", Integer.valueOf(4)); diff --git a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl index 72d44ad..cbc5fd1 100644 --- a/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl +++ b/src/test/resources/freemarker/test/templatesuite/templates/temporal.ftl @@ -21,6 +21,7 @@ <@assertEquals expected="2003-04-05" actual=localDate?string /> <@assertEquals expected="06:07:08" actual=localTime?string /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string /> +<@assertEquals expected="06:07:08Z" actual=offsetTime?string /> <@assertEquals expected="2003" actual=year?string /> <@assertEquals expected="2003-04" actual=yearMonth?string /> <@assertEquals expected="2003-04-05T06:07:08Z[UTC]" actual=zonedDateTime?string /> @@ -31,6 +32,7 @@ <@assertEquals expected="2003-04-05" actual=localDate?string.iso /> <@assertEquals expected="06:07:08" actual=localTime?string.iso /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.iso /> +<@assertEquals expected="06:07:08Z" actual=offsetTime?string /> <@assertEquals expected="2003" actual=year?string.iso /> <@assertEquals expected="2003-04" actual=yearMonth?string.iso /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso /> @@ -41,6 +43,7 @@ <@assertEquals expected="2003-04-05" actual=localDate?string.iso /> <@assertEquals expected="06:07:08" actual=localTime?string.iso /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.iso /> +<@assertEquals expected="06:07:08Z" actual=offsetTime?string /> <@assertEquals expected="2003" actual=year?string.iso /> <@assertEquals expected="2003-04" actual=yearMonth?string.iso /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.iso /> @@ -50,6 +53,7 @@ <@assertEquals expected="2003-04-05" actual=localDate?string.xs /> <@assertEquals expected="06:07:08" actual=localTime?string.xs /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=offsetDateTime?string.xs /> +<@assertEquals expected="06:07:08Z" actual=offsetTime?string /> <@assertEquals expected="2003" actual=year?string.xs /> <@assertEquals expected="2003-04" actual=yearMonth?string.xs /> <@assertEquals expected="2003-04-05T06:07:08Z" actual=zonedDateTime?string.xs />
